Merge pull request #4065 from AstrBotDevs/refactor/provider-source
refactor: SUPER AMAZING model provider refactor
This commit is contained in:
+138
-204
@@ -1,6 +1,7 @@
|
||||
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
|
||||
|
||||
import os
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
@@ -61,7 +62,8 @@ DEFAULT_CONFIG = {
|
||||
"ignore_bot_self_message": False,
|
||||
"ignore_at_all": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_sources": [], # provider sources
|
||||
"provider": [], # models from provider_sources
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
@@ -171,6 +173,22 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
class ChatProviderTemplate(TypedDict):
|
||||
id: str
|
||||
provider_source_id: str
|
||||
model: str
|
||||
modalities: list
|
||||
custom_extra_body: dict[str, Any]
|
||||
|
||||
|
||||
CHAT_PROVIDER_TEMPLATE = {
|
||||
"id": "",
|
||||
"provide_source_id": "",
|
||||
"model": "",
|
||||
"modalities": [],
|
||||
"custom_extra_body": {},
|
||||
}
|
||||
|
||||
"""
|
||||
AstrBot v3 时代的配置元数据,目前仅承担以下功能:
|
||||
|
||||
@@ -844,6 +862,7 @@ CONFIG_METADATA_2 = {
|
||||
"metadata": {
|
||||
"provider": {
|
||||
"type": "list",
|
||||
# provider sources templates
|
||||
"config_template": {
|
||||
"OpenAI": {
|
||||
"id": "openai",
|
||||
@@ -854,107 +873,10 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure",
|
||||
"provider": "azure",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"api_version": "2024-05-01-preview",
|
||||
"key": [],
|
||||
"api_base": "",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.x.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"xai_native_search": False,
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Anthropic": {
|
||||
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
|
||||
"id": "claude",
|
||||
"provider": "anthropic",
|
||||
"type": "anthropic_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Ollama": {
|
||||
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
|
||||
"id": "ollama_default",
|
||||
"provider": "ollama",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"LM Studio": {
|
||||
"id": "lm_studio",
|
||||
"provider": "lm_studio",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["lmstudio"],
|
||||
"api_base": "http://localhost:1234/v1",
|
||||
"model_config": {
|
||||
"model": "llama-3.1-8b",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Gemini(OpenAI兼容)": {
|
||||
"id": "gemini_default",
|
||||
"provider": "google",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Gemini": {
|
||||
"id": "gemini_default",
|
||||
"Google Gemini": {
|
||||
"id": "google_gemini",
|
||||
"provider": "google",
|
||||
"type": "googlegenai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -962,10 +884,6 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
@@ -977,10 +895,42 @@ CONFIG_METADATA_2 = {
|
||||
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Anthropic": {
|
||||
"id": "anthropic",
|
||||
"provider": "anthropic",
|
||||
"type": "anthropic_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
"provider": "moonshot",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.x.ai/v1",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
"xai_native_search": False,
|
||||
},
|
||||
"DeepSeek": {
|
||||
"id": "deepseek_default",
|
||||
"id": "deepseek",
|
||||
"provider": "deepseek",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -988,13 +938,75 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "tool_use"],
|
||||
},
|
||||
"Zhipu": {
|
||||
"id": "zhipu",
|
||||
"provider": "zhipu",
|
||||
"type": "zhipu_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure_openai",
|
||||
"provider": "azure",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"api_version": "2024-05-01-preview",
|
||||
"key": [],
|
||||
"api_base": "",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Ollama": {
|
||||
"id": "ollama",
|
||||
"provider": "ollama",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||
"api_base": "http://127.0.0.1:11434/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"LM Studio": {
|
||||
"id": "lm_studio",
|
||||
"provider": "lm_studio",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["lmstudio"],
|
||||
"api_base": "http://127.0.0.1:1234/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"ModelStack": {
|
||||
"id": "modelstack",
|
||||
"provider": "modelstack",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://modelstack.app/v1",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Gemini_OpenAI_API": {
|
||||
"id": "google_gemini_openai",
|
||||
"provider": "google",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Groq": {
|
||||
"id": "groq_default",
|
||||
"id": "groq",
|
||||
"provider": "groq",
|
||||
"type": "groq_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -1002,13 +1014,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.groq.com/openai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "openai/gpt-oss-20b",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "tool_use"],
|
||||
},
|
||||
"302.AI": {
|
||||
"id": "302ai",
|
||||
@@ -1019,12 +1025,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.302.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"硅基流动": {
|
||||
"SiliconFlow": {
|
||||
"id": "siliconflow",
|
||||
"provider": "siliconflow",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1033,15 +1036,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.siliconflow.cn/v1",
|
||||
"model_config": {
|
||||
"model": "deepseek-ai/DeepSeek-V3",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"PPIO派欧云": {
|
||||
"PPIO": {
|
||||
"id": "ppio",
|
||||
"provider": "ppio",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1050,14 +1047,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.ppinfra.com/v3/openai",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "deepseek/deepseek-r1",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"小马算力": {
|
||||
"TokenPony": {
|
||||
"id": "tokenpony",
|
||||
"provider": "tokenpony",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1066,14 +1058,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.tokenpony.cn/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "kimi-k2-instruct-0905",
|
||||
"temperature": 0.7,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"优云智算": {
|
||||
"Compshare": {
|
||||
"id": "compshare",
|
||||
"provider": "compshare",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1082,42 +1069,18 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.modelverse.cn/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "moonshotai/Kimi-K2-Instruct",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Kimi": {
|
||||
"id": "moonshot",
|
||||
"provider": "moonshot",
|
||||
"ModelScope": {
|
||||
"id": "modelscope",
|
||||
"provider": "modelscope",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"智谱 AI": {
|
||||
"id": "zhipu_default",
|
||||
"provider": "zhipu",
|
||||
"type": "zhipu_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"model_config": {
|
||||
"model": "glm-4-flash",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Dify": {
|
||||
"id": "dify_app_default",
|
||||
@@ -1132,7 +1095,6 @@ CONFIG_METADATA_2 = {
|
||||
"dify_query_input_key": "astrbot_text_query",
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
|
||||
},
|
||||
"Coze": {
|
||||
"id": "coze",
|
||||
@@ -1163,20 +1125,6 @@ CONFIG_METADATA_2 = {
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
},
|
||||
"ModelScope": {
|
||||
"id": "modelscope",
|
||||
"provider": "modelscope",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"FastGPT": {
|
||||
"id": "fastgpt",
|
||||
"provider": "fastgpt",
|
||||
@@ -1200,7 +1148,6 @@ CONFIG_METADATA_2 = {
|
||||
"model": "whisper-1",
|
||||
},
|
||||
"Whisper(Local)": {
|
||||
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
"provider": "openai",
|
||||
"type": "openai_whisper_selfhost",
|
||||
"provider_type": "speech_to_text",
|
||||
@@ -1209,7 +1156,6 @@ CONFIG_METADATA_2 = {
|
||||
"model": "tiny",
|
||||
},
|
||||
"SenseVoice(Local)": {
|
||||
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
"type": "sensevoice_stt_selfhost",
|
||||
"provider": "sensevoice",
|
||||
"provider_type": "speech_to_text",
|
||||
@@ -1231,7 +1177,6 @@ CONFIG_METADATA_2 = {
|
||||
"timeout": "20",
|
||||
},
|
||||
"Edge TTS": {
|
||||
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
|
||||
"id": "edge_tts",
|
||||
"provider": "microsoft",
|
||||
"type": "edge_tts",
|
||||
@@ -1447,6 +1392,10 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"provider_source_id": {
|
||||
"invisible": True,
|
||||
"type": "string",
|
||||
},
|
||||
"xai_native_search": {
|
||||
"description": "启用原生搜索功能",
|
||||
"type": "bool",
|
||||
@@ -2015,7 +1964,6 @@ CONFIG_METADATA_2 = {
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string",
|
||||
"hint": "模型提供商名字。",
|
||||
},
|
||||
"type": {
|
||||
"description": "模型提供商种类",
|
||||
@@ -2035,29 +1983,15 @@ CONFIG_METADATA_2 = {
|
||||
"description": "API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "提供商 API Key。",
|
||||
},
|
||||
"api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
|
||||
},
|
||||
"model_config": {
|
||||
"description": "模型配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"model": {
|
||||
"description": "模型名称",
|
||||
"type": "string",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
|
||||
},
|
||||
"max_tokens": {
|
||||
"description": "模型最大输出长度(tokens)",
|
||||
"type": "int",
|
||||
},
|
||||
"temperature": {"description": "温度", "type": "float"},
|
||||
"top_p": {"description": "Top P值", "type": "float"},
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
"type": "string",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
|
||||
},
|
||||
"dify_api_key": {
|
||||
"description": "API Key",
|
||||
|
||||
@@ -33,6 +33,7 @@ from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
|
||||
from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.llm_metadata import update_llm_metadata
|
||||
from astrbot.core.utils.migra_helper import migra
|
||||
|
||||
from . import astrbot_config, html_renderer
|
||||
@@ -185,6 +186,8 @@ class AstrBotCoreLifecycle:
|
||||
# 初始化关闭控制面板的事件
|
||||
self.dashboard_shutdown_event = asyncio.Event()
|
||||
|
||||
asyncio.create_task(update_llm_metadata())
|
||||
|
||||
def _load(self) -> None:
|
||||
"""加载事件总线和任务并初始化."""
|
||||
# 创建一个异步任务来执行事件总线的 dispatch() 方法
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import traceback
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
@@ -32,10 +33,12 @@ class ProviderManager:
|
||||
persona_mgr: PersonaManager,
|
||||
):
|
||||
self.reload_lock = asyncio.Lock()
|
||||
self.resource_lock = asyncio.Lock()
|
||||
self.persona_mgr = persona_mgr
|
||||
self.acm = acm
|
||||
config = acm.confs["default"]
|
||||
self.providers_config: list = config["provider"]
|
||||
self.provider_sources_config: list = config.get("provider_sources", [])
|
||||
self.provider_settings: dict = config["provider_settings"]
|
||||
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
|
||||
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
|
||||
@@ -148,6 +151,7 @@ class ProviderManager:
|
||||
|
||||
"""
|
||||
provider = None
|
||||
provider_id = None
|
||||
if umo:
|
||||
provider_id = sp.get(
|
||||
f"provider_perf_{provider_type.value}",
|
||||
@@ -185,6 +189,12 @@ class ProviderManager:
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
if not provider and provider_id:
|
||||
logger.warning(
|
||||
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
|
||||
)
|
||||
|
||||
return provider
|
||||
|
||||
async def initialize(self):
|
||||
@@ -251,7 +261,136 @@ class ProviderManager:
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
||||
|
||||
def dynamic_import_provider(self, type: str):
|
||||
"""动态导入提供商适配器模块
|
||||
|
||||
Args:
|
||||
type (str): 提供商请求类型。
|
||||
|
||||
Raises:
|
||||
ImportError: 如果提供商类型未知或无法导入对应模块,则抛出异常。
|
||||
"""
|
||||
match type:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import (
|
||||
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
|
||||
)
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
)
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import (
|
||||
ProviderGoogleGenAI as ProviderGoogleGenAI,
|
||||
)
|
||||
case "sensevoice_stt_selfhost":
|
||||
from .sources.sensevoice_selfhosted_source import (
|
||||
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
|
||||
)
|
||||
case "openai_whisper_api":
|
||||
from .sources.whisper_api_source import (
|
||||
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
|
||||
)
|
||||
case "openai_whisper_selfhost":
|
||||
from .sources.whisper_selfhosted_source import (
|
||||
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
|
||||
)
|
||||
case "xinference_stt":
|
||||
from .sources.xinference_stt_provider import (
|
||||
ProviderXinferenceSTT as ProviderXinferenceSTT,
|
||||
)
|
||||
case "openai_tts_api":
|
||||
from .sources.openai_tts_api_source import (
|
||||
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
|
||||
)
|
||||
case "edge_tts":
|
||||
from .sources.edge_tts_source import (
|
||||
ProviderEdgeTTS as ProviderEdgeTTS,
|
||||
)
|
||||
case "gsv_tts_selfhost":
|
||||
from .sources.gsv_selfhosted_source import (
|
||||
ProviderGSVTTS as ProviderGSVTTS,
|
||||
)
|
||||
case "gsvi_tts_api":
|
||||
from .sources.gsvi_tts_source import (
|
||||
ProviderGSVITTS as ProviderGSVITTS,
|
||||
)
|
||||
case "fishaudio_tts_api":
|
||||
from .sources.fishaudio_tts_api_source import (
|
||||
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
|
||||
)
|
||||
case "dashscope_tts":
|
||||
from .sources.dashscope_tts import (
|
||||
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
|
||||
)
|
||||
case "azure_tts":
|
||||
from .sources.azure_tts_source import (
|
||||
AzureTTSProvider as AzureTTSProvider,
|
||||
)
|
||||
case "minimax_tts_api":
|
||||
from .sources.minimax_tts_api_source import (
|
||||
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
|
||||
)
|
||||
case "volcengine_tts":
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "gemini_tts":
|
||||
from .sources.gemini_tts_source import (
|
||||
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
case "vllm_rerank":
|
||||
from .sources.vllm_rerank_source import (
|
||||
VLLMRerankProvider as VLLMRerankProvider,
|
||||
)
|
||||
case "xinference_rerank":
|
||||
from .sources.xinference_rerank_source import (
|
||||
XinferenceRerankProvider as XinferenceRerankProvider,
|
||||
)
|
||||
case "bailian_rerank":
|
||||
from .sources.bailian_rerank_source import (
|
||||
BailianRerankProvider as BailianRerankProvider,
|
||||
)
|
||||
|
||||
def get_merged_provider_config(self, provider_config: dict) -> dict:
|
||||
"""获取 provider 配置和 provider_source 配置合并后的结果
|
||||
|
||||
Returns:
|
||||
dict: 合并后的 provider 配置,key 为 provider id,value 为合并后的配置字典
|
||||
"""
|
||||
pc = copy.deepcopy(provider_config)
|
||||
provider_source_id = pc.get("provider_source_id", "")
|
||||
if provider_source_id:
|
||||
provider_source = None
|
||||
for ps in self.provider_sources_config:
|
||||
if ps.get("id") == provider_source_id:
|
||||
provider_source = ps
|
||||
break
|
||||
|
||||
if provider_source:
|
||||
# 合并配置,provider 的配置优先级更高
|
||||
merged_config = {**provider_source, **pc}
|
||||
# 保持 id 为 provider 的 id,而不是 source 的 id
|
||||
merged_config["id"] = pc["id"]
|
||||
pc = merged_config
|
||||
return pc
|
||||
|
||||
async def load_provider(self, provider_config: dict):
|
||||
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
|
||||
provider_config = self.get_merged_provider_config(provider_config)
|
||||
|
||||
if not provider_config["enable"]:
|
||||
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
|
||||
return
|
||||
@@ -264,99 +403,7 @@ class ProviderManager:
|
||||
|
||||
# 动态导入
|
||||
try:
|
||||
match provider_config["type"]:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import (
|
||||
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
|
||||
)
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
)
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import (
|
||||
ProviderGoogleGenAI as ProviderGoogleGenAI,
|
||||
)
|
||||
case "sensevoice_stt_selfhost":
|
||||
from .sources.sensevoice_selfhosted_source import (
|
||||
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
|
||||
)
|
||||
case "openai_whisper_api":
|
||||
from .sources.whisper_api_source import (
|
||||
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
|
||||
)
|
||||
case "openai_whisper_selfhost":
|
||||
from .sources.whisper_selfhosted_source import (
|
||||
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
|
||||
)
|
||||
case "xinference_stt":
|
||||
from .sources.xinference_stt_provider import (
|
||||
ProviderXinferenceSTT as ProviderXinferenceSTT,
|
||||
)
|
||||
case "openai_tts_api":
|
||||
from .sources.openai_tts_api_source import (
|
||||
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
|
||||
)
|
||||
case "edge_tts":
|
||||
from .sources.edge_tts_source import (
|
||||
ProviderEdgeTTS as ProviderEdgeTTS,
|
||||
)
|
||||
case "gsv_tts_selfhost":
|
||||
from .sources.gsv_selfhosted_source import (
|
||||
ProviderGSVTTS as ProviderGSVTTS,
|
||||
)
|
||||
case "gsvi_tts_api":
|
||||
from .sources.gsvi_tts_source import (
|
||||
ProviderGSVITTS as ProviderGSVITTS,
|
||||
)
|
||||
case "fishaudio_tts_api":
|
||||
from .sources.fishaudio_tts_api_source import (
|
||||
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
|
||||
)
|
||||
case "dashscope_tts":
|
||||
from .sources.dashscope_tts import (
|
||||
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
|
||||
)
|
||||
case "azure_tts":
|
||||
from .sources.azure_tts_source import (
|
||||
AzureTTSProvider as AzureTTSProvider,
|
||||
)
|
||||
case "minimax_tts_api":
|
||||
from .sources.minimax_tts_api_source import (
|
||||
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
|
||||
)
|
||||
case "volcengine_tts":
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "gemini_tts":
|
||||
from .sources.gemini_tts_source import (
|
||||
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
case "vllm_rerank":
|
||||
from .sources.vllm_rerank_source import (
|
||||
VLLMRerankProvider as VLLMRerankProvider,
|
||||
)
|
||||
case "xinference_rerank":
|
||||
from .sources.xinference_rerank_source import (
|
||||
XinferenceRerankProvider as XinferenceRerankProvider,
|
||||
)
|
||||
case "bailian_rerank":
|
||||
from .sources.bailian_rerank_source import (
|
||||
BailianRerankProvider as BailianRerankProvider,
|
||||
)
|
||||
self.dynamic_import_provider(provider_config["type"])
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
|
||||
@@ -499,6 +546,7 @@ class ProviderManager:
|
||||
|
||||
# 和配置文件保持同步
|
||||
self.providers_config = astrbot_config["provider"]
|
||||
self.provider_sources_config = astrbot_config.get("provider_sources", [])
|
||||
config_ids = [provider["id"] for provider in self.providers_config]
|
||||
logger.info(f"providers in user's config: {config_ids}")
|
||||
for key in list(self.inst_map.keys()):
|
||||
@@ -570,6 +618,68 @@ class ProviderManager:
|
||||
)
|
||||
del self.inst_map[provider_id]
|
||||
|
||||
async def delete_provider(
|
||||
self, provider_id: str | None = None, provider_source_id: str | None = None
|
||||
):
|
||||
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
|
||||
async with self.resource_lock:
|
||||
# delete from config
|
||||
target_prov_ids = []
|
||||
if provider_id:
|
||||
target_prov_ids.append(provider_id)
|
||||
else:
|
||||
for prov in self.providers_config:
|
||||
if prov.get("provider_source_id") == provider_source_id:
|
||||
target_prov_ids.append(prov.get("id"))
|
||||
config = self.acm.default_conf
|
||||
for tpid in target_prov_ids:
|
||||
await self.terminate_provider(tpid)
|
||||
config["provider"] = [
|
||||
prov for prov in config["provider"] if prov.get("id") != tpid
|
||||
]
|
||||
config.save_config()
|
||||
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
|
||||
|
||||
async def update_provider(self, origin_provider_id: str, new_config: dict):
|
||||
"""Update provider config and reload the instance. Config will be saved after update."""
|
||||
async with self.resource_lock:
|
||||
npid = new_config.get("id", None)
|
||||
if not npid:
|
||||
raise ValueError("New provider config must have an 'id' field")
|
||||
config = self.acm.default_conf
|
||||
for provider in config["provider"]:
|
||||
if (
|
||||
provider.get("id", None) == npid
|
||||
and provider.get("id", None) != origin_provider_id
|
||||
):
|
||||
raise ValueError(f"Provider ID {npid} already exists")
|
||||
# update config
|
||||
for idx, provider in enumerate(config["provider"]):
|
||||
if provider.get("id", None) == origin_provider_id:
|
||||
config["provider"][idx] = new_config
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Provider ID {origin_provider_id} not found")
|
||||
config.save_config()
|
||||
# reload instance
|
||||
await self.reload(new_config)
|
||||
|
||||
async def create_provider(self, new_config: dict):
|
||||
"""Add new provider config and load the instance. Config will be saved after addition."""
|
||||
async with self.resource_lock:
|
||||
npid = new_config.get("id", None)
|
||||
if not npid:
|
||||
raise ValueError("New provider config must have an 'id' field")
|
||||
config = self.acm.default_conf
|
||||
for provider in config["provider"]:
|
||||
if provider.get("id", None) == npid:
|
||||
raise ValueError(f"Provider ID {npid} already exists")
|
||||
# add to config
|
||||
config["provider"].append(new_config)
|
||||
config.save_config()
|
||||
# load instance
|
||||
await self.load_provider(new_config)
|
||||
|
||||
async def terminate(self):
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
|
||||
@@ -47,7 +47,7 @@ class ProviderAnthropic(Provider):
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
@@ -130,7 +130,11 @@ class ProviderAnthropic(Provider):
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
payloads["tools"] = tool_list
|
||||
|
||||
completion = await self.client.messages.create(**payloads, stream=False)
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
completion = await self.client.messages.create(
|
||||
**payloads, stream=False, extra_body=extra_body
|
||||
)
|
||||
|
||||
assert isinstance(completion, Message)
|
||||
logger.debug(f"completion: {completion}")
|
||||
@@ -173,11 +177,13 @@ class ProviderAnthropic(Provider):
|
||||
# 用于累积最终结果
|
||||
final_text = ""
|
||||
final_tool_calls = []
|
||||
|
||||
id = None
|
||||
usage = TokenUsage()
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
async with self.client.messages.stream(**payloads) as stream:
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
) as stream:
|
||||
assert isinstance(stream, anthropic.AsyncMessageStream)
|
||||
async for event in stream:
|
||||
if event.type == "message_start":
|
||||
@@ -318,10 +324,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
system_prompt, new_messages = self._prepare_payload(context_query)
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, **model_config}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
@@ -331,7 +336,6 @@ class ProviderAnthropic(Provider):
|
||||
try:
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
except Exception as e:
|
||||
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
|
||||
raise e
|
||||
|
||||
return llm_response
|
||||
@@ -373,10 +377,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
system_prompt, new_messages = self._prepare_payload(context_query)
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, **model_config}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
|
||||
@@ -68,7 +68,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
self.api_base = self.api_base[:-1]
|
||||
|
||||
self._init_client()
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
self._init_safety_settings()
|
||||
|
||||
def _init_client(self) -> None:
|
||||
@@ -689,10 +689,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
@@ -742,10 +741,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
|
||||
@@ -69,8 +69,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self.client.chat.completions.create,
|
||||
).parameters.keys()
|
||||
|
||||
model_config = provider_config.get("model_config", {})
|
||||
model = model_config.get("model", "unknown")
|
||||
model = provider_config.get("model", "unknown")
|
||||
self.set_model(model)
|
||||
|
||||
self.reasoning_key = "reasoning_content"
|
||||
@@ -375,10 +374,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
# xAI origin search tool inject
|
||||
self._maybe_inject_xai_search(payloads, **kwargs)
|
||||
|
||||
@@ -267,6 +267,10 @@ class Context:
|
||||
):
|
||||
"""通过 ID 获取对应的 LLM Provider。"""
|
||||
prov = self.provider_manager.inst_map.get(provider_id)
|
||||
if provider_id and not prov:
|
||||
logger.warning(
|
||||
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
|
||||
)
|
||||
return prov
|
||||
|
||||
def get_all_providers(self) -> list[Provider]:
|
||||
@@ -296,10 +300,6 @@ class Context:
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
if prov is None:
|
||||
raise ProviderNotFoundError(
|
||||
"provider not found, please choose provider first"
|
||||
)
|
||||
if not isinstance(prov, Provider):
|
||||
raise ValueError("返回的 Provider 不是 Provider 类型")
|
||||
return prov
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
class LLMModalities(TypedDict):
|
||||
input: list[Literal["text", "image", "audio", "video"]]
|
||||
output: list[Literal["text", "image", "audio", "video"]]
|
||||
|
||||
|
||||
class LLMLimit(TypedDict):
|
||||
context: int
|
||||
output: int
|
||||
|
||||
|
||||
class LLMMetadata(TypedDict):
|
||||
id: str
|
||||
reasoning: bool
|
||||
tool_call: bool
|
||||
knowledge: str
|
||||
release_date: str
|
||||
modalities: LLMModalities
|
||||
open_weights: bool
|
||||
limit: LLMLimit
|
||||
|
||||
|
||||
LLM_METADATAS: dict[str, LLMMetadata] = {}
|
||||
|
||||
|
||||
async def update_llm_metadata():
|
||||
url = "https://models.dev/api.json"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
data = await response.json()
|
||||
global LLM_METADATAS
|
||||
models = {}
|
||||
for info in data.values():
|
||||
for model in info.get("models", {}).values():
|
||||
model_id = model.get("id")
|
||||
if not model_id:
|
||||
continue
|
||||
models[model_id] = LLMMetadata(
|
||||
id=model_id,
|
||||
reasoning=model.get("reasoning", False),
|
||||
tool_call=model.get("tool_call", False),
|
||||
knowledge=model.get("knowledge", "none"),
|
||||
release_date=model.get("release_date", ""),
|
||||
modalities=model.get(
|
||||
"modalities", {"input": [], "output": []}
|
||||
),
|
||||
open_weights=model.get("open_weights", False),
|
||||
limit=model.get("limit", {"context": 0, "output": 0}),
|
||||
)
|
||||
# Replace the global cache in-place so references remain valid
|
||||
LLM_METADATAS.clear()
|
||||
LLM_METADATAS.update(models)
|
||||
logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch LLM metadata: {e}")
|
||||
return
|
||||
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
|
||||
"""
|
||||
Migrate old provider structure to new provider-source separation.
|
||||
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
|
||||
All other fields move to provider_sources.
|
||||
"""
|
||||
providers = conf.get("provider", [])
|
||||
provider_sources = conf.get("provider_sources", [])
|
||||
|
||||
# Track if any migration happened
|
||||
migrated = False
|
||||
|
||||
# Provider-only fields that should stay in provider
|
||||
provider_only_fields = {
|
||||
"id",
|
||||
"provider_source_id",
|
||||
"model",
|
||||
"modalities",
|
||||
"custom_extra_body",
|
||||
"enable",
|
||||
}
|
||||
|
||||
# Fields that should not go to source
|
||||
source_exclude_fields = provider_only_fields | {"model_config"}
|
||||
|
||||
for provider in providers:
|
||||
# Skip if already has provider_source_id
|
||||
if provider.get("provider_source_id"):
|
||||
continue
|
||||
|
||||
# Skip non-chat-completion types (they don't need source separation)
|
||||
provider_type = provider.get("provider_type", "")
|
||||
if provider_type != "chat_completion":
|
||||
# For old types without provider_type, check type field
|
||||
old_type = provider.get("type", "")
|
||||
if "chat_completion" not in old_type:
|
||||
continue
|
||||
|
||||
migrated = True
|
||||
logger.info(f"Migrating provider {provider.get('id')} to new structure")
|
||||
|
||||
# Extract source fields from provider
|
||||
source_fields = {}
|
||||
for key, value in list(provider.items()):
|
||||
if key not in source_exclude_fields:
|
||||
source_fields[key] = value
|
||||
|
||||
# Create new provider_source
|
||||
source_id = provider.get("id", "") + "_source"
|
||||
new_source = {"id": source_id, **source_fields}
|
||||
|
||||
# Update provider to only keep necessary fields
|
||||
provider["provider_source_id"] = source_id
|
||||
|
||||
# Extract model from model_config if exists
|
||||
if "model_config" in provider and isinstance(provider["model_config"], dict):
|
||||
model_config = provider["model_config"]
|
||||
provider["model"] = model_config.get("model", "")
|
||||
|
||||
# Put other model_config fields into custom_extra_body
|
||||
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
|
||||
if extra_body_fields:
|
||||
if "custom_extra_body" not in provider:
|
||||
provider["custom_extra_body"] = {}
|
||||
provider["custom_extra_body"].update(extra_body_fields)
|
||||
|
||||
# Initialize new fields if not present
|
||||
if "modalities" not in provider:
|
||||
provider["modalities"] = []
|
||||
if "custom_extra_body" not in provider:
|
||||
provider["custom_extra_body"] = {}
|
||||
|
||||
# Remove fields that should be in source
|
||||
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
|
||||
for key in keys_to_remove:
|
||||
del provider[key]
|
||||
|
||||
# Add source to provider_sources
|
||||
provider_sources.append(new_source)
|
||||
|
||||
if migrated:
|
||||
conf["provider_sources"] = provider_sources
|
||||
conf.save_config()
|
||||
logger.info("Provider-source structure migration completed")
|
||||
|
||||
|
||||
async def migra(
|
||||
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
|
||||
) -> None:
|
||||
@@ -71,3 +157,10 @@ async def migra(
|
||||
|
||||
for conf in acm.confs.values():
|
||||
_migra_agent_runner_configs(conf, ids_map)
|
||||
|
||||
# Migrate providers to new structure: extract source fields to provider_sources
|
||||
try:
|
||||
_migra_provider_to_source_structure(astrbot_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Migration for provider-source structure failed: {e!s}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import file_token_service, logger
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import (
|
||||
CONFIG_METADATA_2,
|
||||
@@ -21,6 +21,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -179,13 +180,149 @@ class ConfigRoute(Route):
|
||||
"/config/provider/new": ("POST", self.post_new_provider),
|
||||
"/config/provider/update": ("POST", self.post_update_provider),
|
||||
"/config/provider/delete": ("POST", self.post_delete_provider),
|
||||
"/config/provider/template": ("GET", self.get_provider_template),
|
||||
"/config/provider/check_one": ("GET", self.check_one_provider_status),
|
||||
"/config/provider/list": ("GET", self.get_provider_config_list),
|
||||
"/config/provider/model_list": ("GET", self.get_provider_model_list),
|
||||
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
|
||||
"/config/provider_sources/<provider_source_id>/models": (
|
||||
"GET",
|
||||
self.get_provider_source_models,
|
||||
),
|
||||
"/config/provider_sources/<provider_source_id>/update": (
|
||||
"POST",
|
||||
self.update_provider_source,
|
||||
),
|
||||
"/config/provider_sources/<provider_source_id>/delete": (
|
||||
"POST",
|
||||
self.delete_provider_source,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def delete_provider_source(self, provider_source_id: str):
|
||||
"""删除 provider_source,并更新关联的 providers"""
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
target_idx = next(
|
||||
(
|
||||
i
|
||||
for i, ps in enumerate(provider_sources)
|
||||
if ps.get("id") == provider_source_id
|
||||
),
|
||||
-1,
|
||||
)
|
||||
|
||||
if target_idx == -1:
|
||||
return Response().error("未找到对应的 provider source").__dict__
|
||||
|
||||
# 删除 provider_source
|
||||
del provider_sources[target_idx]
|
||||
|
||||
# 写回配置
|
||||
self.config["provider_sources"] = provider_sources
|
||||
|
||||
# 删除引用了该 provider_source 的 providers
|
||||
await self.core_lifecycle.provider_manager.delete_provider(
|
||||
provider_source_id=provider_source_id
|
||||
)
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
return Response().ok(message="删除 provider source 成功").__dict__
|
||||
|
||||
async def update_provider_source(self, provider_source_id: str):
|
||||
"""更新或新增 provider_source,并重载关联的 providers"""
|
||||
|
||||
post_data = await request.json
|
||||
if not post_data:
|
||||
return Response().error("缺少配置数据").__dict__
|
||||
|
||||
new_source_config = post_data.get("config") or post_data
|
||||
original_id = provider_source_id
|
||||
|
||||
if not isinstance(new_source_config, dict):
|
||||
return Response().error("缺少或错误的配置数据").__dict__
|
||||
|
||||
# 确保配置中有 id 字段
|
||||
if not new_source_config.get("id"):
|
||||
new_source_config["id"] = original_id
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
|
||||
for ps in provider_sources:
|
||||
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 查找旧的 provider_source,若不存在则追加为新配置
|
||||
target_idx = next(
|
||||
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
|
||||
-1,
|
||||
)
|
||||
|
||||
old_id = original_id
|
||||
if target_idx == -1:
|
||||
provider_sources.append(new_source_config)
|
||||
else:
|
||||
old_id = provider_sources[target_idx].get("id")
|
||||
provider_sources[target_idx] = new_source_config
|
||||
|
||||
# 更新引用了该 provider_source 的 providers
|
||||
affected_providers = []
|
||||
for provider in self.config.get("provider", []):
|
||||
if provider.get("provider_source_id") == old_id:
|
||||
provider["provider_source_id"] = new_source_config["id"]
|
||||
affected_providers.append(provider)
|
||||
|
||||
# 写回配置
|
||||
self.config["provider_sources"] = provider_sources
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
# 重载受影响的 providers,使新的 source 配置生效
|
||||
reload_errors = []
|
||||
prov_mgr = self.core_lifecycle.provider_manager
|
||||
for provider in affected_providers:
|
||||
try:
|
||||
await prov_mgr.reload(provider)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
reload_errors.append(f"{provider.get('id')}: {e}")
|
||||
|
||||
if reload_errors:
|
||||
return (
|
||||
Response()
|
||||
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return Response().ok(message="更新 provider source 成功").__dict__
|
||||
|
||||
async def get_provider_template(self):
|
||||
config_schema = {
|
||||
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
|
||||
}
|
||||
data = {
|
||||
"config_schema": config_schema,
|
||||
"providers": astrbot_config["provider"],
|
||||
"provider_sources": astrbot_config["provider_sources"],
|
||||
}
|
||||
return Response().ok(data=data).__dict__
|
||||
|
||||
async def get_uc_table(self):
|
||||
"""获取 UMOP 配置路由表"""
|
||||
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
|
||||
@@ -433,9 +570,25 @@ class ConfigRoute(Route):
|
||||
return Response().error("缺少参数 provider_type").__dict__
|
||||
provider_type_ls = provider_type.split(",")
|
||||
provider_list = []
|
||||
astrbot_config = self.core_lifecycle.astrbot_config
|
||||
for provider in astrbot_config["provider"]:
|
||||
if provider.get("provider_type", None) in provider_type_ls:
|
||||
ps = self.core_lifecycle.provider_manager.providers_config
|
||||
p_source_pt = {
|
||||
psrc["id"]: psrc["provider_type"]
|
||||
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
|
||||
}
|
||||
for provider in ps:
|
||||
ps_id = provider.get("provider_source_id", None)
|
||||
if (
|
||||
ps_id
|
||||
and ps_id in p_source_pt
|
||||
and p_source_pt[ps_id] in provider_type_ls
|
||||
):
|
||||
# chat
|
||||
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
|
||||
provider
|
||||
)
|
||||
provider_list.append(prov)
|
||||
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
|
||||
# agent runner, embedding, etc
|
||||
provider_list.append(provider)
|
||||
return Response().ok(provider_list).__dict__
|
||||
|
||||
@@ -458,9 +611,18 @@ class ConfigRoute(Route):
|
||||
|
||||
try:
|
||||
models = await provider.get_models()
|
||||
models = models or []
|
||||
|
||||
metadata_map = {}
|
||||
for model_id in models:
|
||||
meta = LLM_METADATAS.get(model_id)
|
||||
if meta:
|
||||
metadata_map[model_id] = meta
|
||||
|
||||
ret = {
|
||||
"models": models,
|
||||
"provider_id": provider_id,
|
||||
"model_metadata": metadata_map,
|
||||
}
|
||||
return Response().ok(ret).__dict__
|
||||
except Exception as e:
|
||||
@@ -522,6 +684,100 @@ class ConfigRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
|
||||
|
||||
async def get_provider_source_models(self, provider_source_id: str):
|
||||
"""获取指定 provider_source 支持的模型列表
|
||||
|
||||
本质上会临时初始化一个 Provider 实例,调用 get_models() 获取模型列表,然后销毁实例
|
||||
"""
|
||||
try:
|
||||
from astrbot.core.provider.register import provider_cls_map
|
||||
|
||||
# 从配置中查找对应的 provider_source
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
provider_source = None
|
||||
for ps in provider_sources:
|
||||
if ps.get("id") == provider_source_id:
|
||||
provider_source = ps
|
||||
break
|
||||
|
||||
if not provider_source:
|
||||
return (
|
||||
Response()
|
||||
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 获取 provider 类型
|
||||
provider_type = provider_source.get("type", None)
|
||||
if not provider_type:
|
||||
return Response().error("provider_source 缺少 type 字段").__dict__
|
||||
|
||||
try:
|
||||
self.core_lifecycle.provider_manager.dynamic_import_provider(
|
||||
provider_type
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__
|
||||
|
||||
# 获取对应的 provider 类
|
||||
if provider_type not in provider_cls_map:
|
||||
return (
|
||||
Response()
|
||||
.error(f"未找到适用于 {provider_type} 的提供商适配器")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
provider_metadata = provider_cls_map[provider_type]
|
||||
cls_type = provider_metadata.cls_type
|
||||
|
||||
if not cls_type:
|
||||
return Response().error(f"无法找到 {provider_type} 的类").__dict__
|
||||
|
||||
# 检查是否是 Provider 类型
|
||||
if not issubclass(cls_type, Provider):
|
||||
return (
|
||||
Response()
|
||||
.error(f"提供商 {provider_type} 不支持获取模型列表")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 临时实例化 provider
|
||||
inst = cls_type(provider_source, {})
|
||||
|
||||
# 如果有 initialize 方法,调用它
|
||||
init_fn = getattr(inst, "initialize", None)
|
||||
if inspect.iscoroutinefunction(init_fn):
|
||||
await init_fn()
|
||||
|
||||
# 获取模型列表
|
||||
models = await inst.get_models()
|
||||
models = models or []
|
||||
|
||||
metadata_map = {}
|
||||
for model_id in models:
|
||||
meta = LLM_METADATAS.get(model_id)
|
||||
if meta:
|
||||
metadata_map[model_id] = meta
|
||||
|
||||
# 销毁实例(如果有 terminate 方法)
|
||||
terminate_fn = getattr(inst, "terminate", None)
|
||||
if inspect.iscoroutinefunction(terminate_fn):
|
||||
await terminate_fn()
|
||||
|
||||
logger.info(
|
||||
f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"models": models, "model_metadata": metadata_map})
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取模型列表失败: {e!s}").__dict__
|
||||
|
||||
async def get_platform_list(self):
|
||||
"""获取所有平台的列表"""
|
||||
platform_list = []
|
||||
@@ -533,7 +789,15 @@ class ConfigRoute(Route):
|
||||
data = await request.json
|
||||
config = data.get("config", None)
|
||||
conf_id = data.get("conf_id", None)
|
||||
|
||||
try:
|
||||
# 不更新 provider_sources, provider, platform
|
||||
# 这些配置有单独的接口进行更新
|
||||
if conf_id == "default":
|
||||
no_update_keys = ["provider_sources", "provider", "platform"]
|
||||
for key in no_update_keys:
|
||||
config[key] = self.acm.default_conf[key]
|
||||
|
||||
await self._save_astrbot_configs(config, conf_id)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return Response().ok(None, "保存成功~").__dict__
|
||||
@@ -573,28 +837,30 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_new_provider(self):
|
||||
new_provider_config = await request.json
|
||||
self.config["provider"].append(new_provider_config)
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.load_provider(
|
||||
new_provider_config,
|
||||
await self.core_lifecycle.provider_manager.create_provider(
|
||||
new_provider_config
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "新增服务提供商配置成功~").__dict__
|
||||
return Response().ok(None, "新增服务提供商配置成功").__dict__
|
||||
|
||||
async def post_update_platform(self):
|
||||
update_platform_config = await request.json
|
||||
platform_id = update_platform_config.get("id", None)
|
||||
origin_platform_id = update_platform_config.get("id", None)
|
||||
new_config = update_platform_config.get("config", None)
|
||||
if not platform_id or not new_config:
|
||||
if not origin_platform_id or not new_config:
|
||||
return Response().error("参数错误").__dict__
|
||||
|
||||
if origin_platform_id != new_config.get("id", None):
|
||||
return Response().error("机器人名称不允许修改").__dict__
|
||||
|
||||
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
|
||||
ensure_platform_webhook_config(new_config)
|
||||
|
||||
for i, platform in enumerate(self.config["platform"]):
|
||||
if platform["id"] == platform_id:
|
||||
if platform["id"] == origin_platform_id:
|
||||
self.config["platform"][i] = new_config
|
||||
break
|
||||
else:
|
||||
@@ -609,21 +875,15 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_update_provider(self):
|
||||
update_provider_config = await request.json
|
||||
provider_id = update_provider_config.get("id", None)
|
||||
origin_provider_id = update_provider_config.get("id", None)
|
||||
new_config = update_provider_config.get("config", None)
|
||||
if not provider_id or not new_config:
|
||||
if not origin_provider_id or not new_config:
|
||||
return Response().error("参数错误").__dict__
|
||||
|
||||
for i, provider in enumerate(self.config["provider"]):
|
||||
if provider["id"] == provider_id:
|
||||
self.config["provider"][i] = new_config
|
||||
break
|
||||
else:
|
||||
return Response().error("未找到对应服务提供商").__dict__
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.reload(new_config)
|
||||
await self.core_lifecycle.provider_manager.update_provider(
|
||||
origin_provider_id, new_config
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "更新成功,已经实时生效~").__dict__
|
||||
@@ -646,19 +906,17 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_delete_provider(self):
|
||||
provider_id = await request.json
|
||||
provider_id = provider_id.get("id")
|
||||
for i, provider in enumerate(self.config["provider"]):
|
||||
if provider["id"] == provider_id:
|
||||
del self.config["provider"][i]
|
||||
break
|
||||
else:
|
||||
return Response().error("未找到对应服务提供商").__dict__
|
||||
provider_id = provider_id.get("id", "")
|
||||
if not provider_id:
|
||||
return Response().error("缺少参数 id").__dict__
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
|
||||
await self.core_lifecycle.provider_manager.delete_provider(
|
||||
provider_id=provider_id
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "删除成功,已经实时生效~").__dict__
|
||||
return Response().ok(None, "删除成功,已经实时生效。").__dict__
|
||||
|
||||
async def get_llm_tools(self):
|
||||
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 48 KiB |
@@ -26,7 +26,9 @@
|
||||
:initial-config-id="props.configId"
|
||||
@config-changed="handleConfigChange"
|
||||
/>
|
||||
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
|
||||
|
||||
<!-- Provider/Model Selector Menu -->
|
||||
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
||||
|
||||
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
@@ -84,8 +86,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import ProviderModelSelector from './ProviderModelSelector.vue';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
import ProviderModelMenu from './ProviderModelMenu.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface StagedFileInfo {
|
||||
@@ -141,7 +143,7 @@ const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const inputField = ref<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
|
||||
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
|
||||
const showProviderSelector = ref(true);
|
||||
|
||||
const localPrompt = computed({
|
||||
@@ -234,7 +236,7 @@ function getCurrentSelection() {
|
||||
if (!showProviderSelector.value) {
|
||||
return null;
|
||||
}
|
||||
return providerModelSelectorRef.value?.getCurrentSelection();
|
||||
return providerModelMenuRef.value?.getCurrentSelection();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="480" persistent>
|
||||
<v-dialog v-model="dialog" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>选择配置文件</span>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<v-menu :close-on-content-click="false" location="top">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
<span v-if="selectedProviderId">
|
||||
{{ selectedProviderId }}
|
||||
</span>
|
||||
<span v-else>Model</span>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-card class="provider-menu-card" min-width="280" max-width="400">
|
||||
<v-card-text class="pa-2">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
hide-details
|
||||
variant="plain"
|
||||
flat
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
class="ml-2 mb-2 mr-2"
|
||||
clearable
|
||||
/>
|
||||
<v-list density="compact" nav class="provider-menu-list">
|
||||
<v-list-item v-for="provider in filteredProviders" :key="provider.id"
|
||||
:active="selectedProviderId === provider.id" @click="selectProvider(provider)" rounded="lg"
|
||||
class="provider-menu-item">
|
||||
<v-list-item-title class="text-body-2">{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="provider-subtitle">
|
||||
<span class="model-name">{{ provider.model }}</span>
|
||||
<span class="meta-icons">
|
||||
<v-tooltip text="支持图像输入" location="top" v-if="supportsImageInput(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-eye-outline</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="支持工具调用" location="top" v-if="supportsToolCall(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-wrench</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="支持推理" location="top" v-if="supportsReasoning(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-brain</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="providerConfigs.length === 0" class="empty-hint">
|
||||
No available models
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ModelMetadata {
|
||||
modalities?: { input?: string[] };
|
||||
tool_call?: boolean;
|
||||
reasoning?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
id: string;
|
||||
model: string;
|
||||
api_base?: string;
|
||||
model_metadata?: ModelMetadata;
|
||||
}
|
||||
|
||||
const providerConfigs = ref<ProviderConfig[]>([]);
|
||||
const selectedProviderId = ref('');
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return providerConfigs.value;
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return providerConfigs.value.filter(p =>
|
||||
p.id.toLowerCase().includes(query) ||
|
||||
p.model.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
function loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
if (savedProvider) {
|
||||
selectedProviderId.value = savedProvider;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
if (selectedProviderId.value) {
|
||||
localStorage.setItem('selectedProvider', selectedProviderId.value);
|
||||
}
|
||||
}
|
||||
|
||||
function loadProviderConfigs() {
|
||||
axios.get('/api/config/provider/list', {
|
||||
params: { provider_type: 'chat_completion' }
|
||||
}).then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
providerConfigs.value = response.data.data || [];
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('获取提供商列表失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function selectProvider(provider: ProviderConfig) {
|
||||
selectedProviderId.value = provider.id;
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
function supportsImageInput(provider: ProviderConfig): boolean {
|
||||
const inputs = provider.model_metadata?.modalities?.input || [];
|
||||
return inputs.includes('image');
|
||||
}
|
||||
|
||||
function supportsToolCall(provider: ProviderConfig): boolean {
|
||||
return Boolean(provider.model_metadata?.tool_call);
|
||||
}
|
||||
|
||||
function supportsReasoning(provider: ProviderConfig): boolean {
|
||||
return Boolean(provider.model_metadata?.reasoning);
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
const provider = providerConfigs.value.find(p => p.id === selectedProviderId.value);
|
||||
return {
|
||||
providerId: selectedProviderId.value,
|
||||
modelName: provider?.model || ''
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFromStorage();
|
||||
loadProviderConfigs();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getCurrentSelection
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-menu-card {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.provider-menu-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-menu-item {
|
||||
margin-bottom: 2px;
|
||||
border-radius: 8px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.provider-menu-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.provider-menu-item.v-list-item--active {
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
}
|
||||
|
||||
.provider-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.meta-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,359 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 选择提供商和模型按钮 -->
|
||||
<v-chip class="text-none" variant="tonal" size="x-small"
|
||||
v-if="selectedProviderId && selectedModelName" @click="openDialog">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
{{ selectedProviderId }} / {{ selectedModelName }}
|
||||
</v-chip>
|
||||
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
|
||||
选择模型
|
||||
</v-chip>
|
||||
|
||||
<!-- 选择提供商和模型对话框 -->
|
||||
<v-dialog v-model="showDialog" max-width="800">
|
||||
<v-card style="padding: 8px;">
|
||||
<v-card-title class="dialog-title">
|
||||
<span>选择提供商和模型</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<div class="provider-model-container">
|
||||
<!-- 左侧提供商列表 -->
|
||||
<div class="provider-list-panel">
|
||||
<div class="panel-header">
|
||||
<h4>提供商</h4>
|
||||
</div>
|
||||
<v-list density="compact" nav class="provider-list">
|
||||
<v-list-item v-for="provider in providerConfigs" :key="provider.id" :value="provider.id"
|
||||
@click="selectProvider(provider)" :active="tempSelectedProviderId === provider.id"
|
||||
rounded="lg" class="provider-item">
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="providerConfigs.length === 0" class="empty-state">
|
||||
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">暂无可用提供商</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧模型列表 -->
|
||||
<div class="model-list-panel">
|
||||
<div class="panel-header">
|
||||
<h4>模型</h4>
|
||||
<v-btn v-if="tempSelectedProviderId" icon="mdi-refresh" size="small" variant="text"
|
||||
@click="refreshModels" :loading="loadingModels">
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-list density="compact" nav class="model-list" v-if="tempSelectedProviderId">
|
||||
|
||||
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
|
||||
|
||||
<v-list-item v-for="model in modelList" :key="model" :value="model"
|
||||
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
|
||||
class="model-item">
|
||||
<v-list-item-title>{{ model }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="model.description">{{ model.description
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="empty-state">
|
||||
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">请先选择提供商</div>
|
||||
</div>
|
||||
<div v-if="tempSelectedProviderId && modelList.length === 0 && !loadingModels"
|
||||
class="empty-state">
|
||||
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">该提供商暂无可用模型</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
|
||||
<v-btn text @click="confirmSelection" color="primary"
|
||||
:disabled="!tempSelectedProviderId || !tempSelectedModelName">
|
||||
确认选择
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'ProviderModelSelector',
|
||||
props: {
|
||||
initialProvider: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
initialModel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['selection-changed'],
|
||||
data() {
|
||||
return {
|
||||
showDialog: false,
|
||||
providerConfigs: [],
|
||||
modelList: [],
|
||||
selectedProviderId: '',
|
||||
selectedModelName: '',
|
||||
// 临时选择状态,用于对话框内的选择
|
||||
tempSelectedProviderId: '',
|
||||
tempSelectedModelName: '',
|
||||
loadingModels: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 从localStorage加载保存的选择
|
||||
this.loadFromStorage();
|
||||
// 初始化临时选择
|
||||
this.resetTempSelection();
|
||||
// 获取提供商列表
|
||||
this.loadProviderConfigs();
|
||||
// 如果有保存的选择,加载对应的模型列表
|
||||
if (this.selectedProviderId) {
|
||||
this.getProviderModels(this.selectedProviderId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 从localStorage加载保存的选择
|
||||
loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
const savedModel = localStorage.getItem('selectedModel');
|
||||
|
||||
if (savedProvider) {
|
||||
this.selectedProviderId = savedProvider;
|
||||
} else if (this.initialProvider) {
|
||||
this.selectedProviderId = this.initialProvider;
|
||||
}
|
||||
|
||||
if (savedModel) {
|
||||
this.selectedModelName = savedModel;
|
||||
} else if (this.initialModel) {
|
||||
this.selectedModelName = this.initialModel;
|
||||
}
|
||||
},
|
||||
|
||||
// 保存到localStorage
|
||||
saveToStorage() {
|
||||
if (this.selectedProviderId) {
|
||||
localStorage.setItem('selectedProvider', this.selectedProviderId);
|
||||
}
|
||||
if (this.selectedModelName) {
|
||||
localStorage.setItem('selectedModel', this.selectedModelName);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取提供商配置列表
|
||||
loadProviderConfigs() {
|
||||
axios.get('/api/config/provider/list', {
|
||||
params: {
|
||||
provider_type: 'chat_completion'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.providerConfigs = response.data.data || [];
|
||||
} else {
|
||||
console.error('获取聊天完成提供商列表失败:', response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取聊天完成提供商列表失败:', error);
|
||||
});
|
||||
},
|
||||
|
||||
// 获取指定提供商的模型列表
|
||||
getProviderModels(providerId) {
|
||||
this.loadingModels = true;
|
||||
axios.get('/api/config/provider/model_list', {
|
||||
params: {
|
||||
provider_id: providerId
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.modelList = response.data.data.models || [];
|
||||
} else {
|
||||
console.error('获取模型列表失败:', response.data.message);
|
||||
this.modelList = [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取模型列表失败:', error);
|
||||
this.modelList = [];
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingModels = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 选择提供商
|
||||
selectProvider(provider) {
|
||||
this.tempSelectedProviderId = provider.id;
|
||||
this.tempSelectedModelName = ''; // 清空已选择的模型
|
||||
this.modelList = []; // 清空模型列表
|
||||
this.getProviderModels(provider.id); // 获取该提供商的模型列表
|
||||
},
|
||||
|
||||
// 选择模型
|
||||
selectModel(model) {
|
||||
this.tempSelectedModelName = model;
|
||||
},
|
||||
|
||||
// 刷新模型列表
|
||||
refreshModels() {
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 确认选择
|
||||
confirmSelection() {
|
||||
if (this.tempSelectedProviderId && this.tempSelectedModelName) {
|
||||
// 将临时选择应用到正式选择
|
||||
this.selectedProviderId = this.tempSelectedProviderId;
|
||||
this.selectedModelName = this.tempSelectedModelName;
|
||||
|
||||
// 保存到localStorage
|
||||
this.saveToStorage();
|
||||
|
||||
// 触发事件通知父组件
|
||||
this.$emit('selection-changed', {
|
||||
providerId: this.selectedProviderId,
|
||||
modelName: this.selectedModelName
|
||||
});
|
||||
|
||||
this.closeDialog();
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭对话框
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
// 重置临时选择为当前选择
|
||||
this.resetTempSelection();
|
||||
},
|
||||
|
||||
// 重置临时选择
|
||||
resetTempSelection() {
|
||||
this.tempSelectedProviderId = this.selectedProviderId;
|
||||
this.tempSelectedModelName = this.selectedModelName;
|
||||
// 如果有临时选择的提供商,重新加载模型列表
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 打开对话框
|
||||
openDialog() {
|
||||
this.resetTempSelection();
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
// 公开方法:获取当前选择
|
||||
getCurrentSelection() {
|
||||
return {
|
||||
providerId: this.selectedProviderId,
|
||||
modelName: this.selectedModelName
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 对话框标题样式 */
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 提供商和模型选择对话框样式 */
|
||||
.provider-model-container {
|
||||
display: flex;
|
||||
height: 500px;
|
||||
border: 1px solid var(--v-theme-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-list-panel,
|
||||
.model-list-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.provider-list-panel {
|
||||
border-right: 1px solid var(--v-theme-border);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
background-color: var(--v-theme-containerBg);
|
||||
}
|
||||
|
||||
.panel-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.provider-list,
|
||||
.model-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.provider-item,
|
||||
.model-item {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-item:hover,
|
||||
.model-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.provider-item.v-list-item--active,
|
||||
.model-item.v-list-item--active {
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
opacity: 0.6;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
</style>
|
||||
@@ -394,6 +394,9 @@ export default {
|
||||
// 配置抽屉
|
||||
showConfigDrawer: false,
|
||||
configDrawerTargetId: null,
|
||||
|
||||
// 保存更新前的平台 ID,防止用户修改 ID 后丢失原始定位
|
||||
originalUpdatingPlatformId: null,
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
@@ -481,6 +484,7 @@ export default {
|
||||
updatingPlatformConfig: {
|
||||
handler(newConfig) {
|
||||
if (this.updatingMode && newConfig && newConfig.id) {
|
||||
this.originalUpdatingPlatformId = newConfig.id;
|
||||
this.getPlatformConfigs(newConfig.id);
|
||||
}
|
||||
},
|
||||
@@ -533,6 +537,8 @@ export default {
|
||||
|
||||
this.showConfigDrawer = false;
|
||||
this.configDrawerTargetId = null;
|
||||
|
||||
this.originalUpdatingPlatformId = null;
|
||||
},
|
||||
closeDialog() {
|
||||
this.resetForm();
|
||||
@@ -624,7 +630,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async updatePlatform() {
|
||||
let id = this.updatingPlatformConfig.id;
|
||||
const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
|
||||
if (!id) {
|
||||
this.loading = false;
|
||||
this.showError('更新失败,缺少平台 ID。');
|
||||
@@ -633,11 +639,15 @@ export default {
|
||||
|
||||
try {
|
||||
// 更新平台配置
|
||||
await axios.post('/api/config/platform/update', {
|
||||
let resp = await axios.post('/api/config/platform/update', {
|
||||
id: id,
|
||||
config: this.updatingPlatformConfig
|
||||
});
|
||||
})
|
||||
|
||||
if (resp.data.status === 'error') {
|
||||
throw new Error(resp.data.message || '平台更新失败');
|
||||
}
|
||||
|
||||
// 同时更新路由表
|
||||
await this.saveRoutesInternal();
|
||||
|
||||
@@ -885,7 +895,10 @@ export default {
|
||||
|
||||
// 内部保存路由表方法(不显示成功提示)
|
||||
async saveRoutesInternal() {
|
||||
if (!this.updatingPlatformConfig || !this.updatingPlatformConfig.id) {
|
||||
const originalPlatformId = this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
|
||||
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
|
||||
|
||||
if (!originalPlatformId && !newPlatformId) {
|
||||
throw new Error('无法获取平台 ID');
|
||||
}
|
||||
|
||||
@@ -895,9 +908,11 @@ export default {
|
||||
const fullRoutingTable = routesRes.data.data.routing;
|
||||
|
||||
// 删除该平台的所有旧路由
|
||||
const platformId = this.updatingPlatformConfig.id;
|
||||
for (const umop in fullRoutingTable) {
|
||||
if (this.isUmopMatchPlatform(umop, platformId)) {
|
||||
if (
|
||||
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
|
||||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
|
||||
) {
|
||||
delete fullRoutingTable[umop];
|
||||
}
|
||||
}
|
||||
@@ -906,7 +921,8 @@ export default {
|
||||
for (const route of this.platformRoutes) {
|
||||
const messageType = route.messageType === '*' ? '*' : route.messageType;
|
||||
const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
|
||||
const newUmop = `${platformId}:${messageType}:${sessionId}`;
|
||||
const platformIdForRoute = newPlatformId || originalPlatformId;
|
||||
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
|
||||
|
||||
if (route.configId) {
|
||||
fullRoutingTable[newUmop] = route.configId;
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
<v-card :title="tm('dialogs.addProvider.title')">
|
||||
<v-card-text style="overflow-y: auto;">
|
||||
<v-tabs v-model="activeProviderTab" grow>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.basic') }}
|
||||
</v-tab>
|
||||
<v-tab value="agent_runner" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-cogs</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
|
||||
@@ -116,7 +112,7 @@ export default {
|
||||
|
||||
// 按提供商类型获取模板列表
|
||||
getTemplatesByType(type) {
|
||||
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
const templates = this.metadata.provider.config_template || {};
|
||||
const filtered = {};
|
||||
|
||||
for (const [name, template] of Object.entries(templates)) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
|
||||
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="ml-1"
|
||||
style="max-width: 240px;"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:loading="loadingModels"
|
||||
@click="emit('fetch-models')"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
@click="emit('open-manual-model')"
|
||||
>
|
||||
{{ tm('models.manualAddButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
class="rounded-lg border"
|
||||
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
|
||||
>
|
||||
<template v-if="entries.length > 0">
|
||||
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
|
||||
<v-list-item
|
||||
v-if="entry.type === 'configured'"
|
||||
class="provider-compact-item"
|
||||
@click="emit('open-provider-edit', entry.provider)"
|
||||
>
|
||||
<v-list-item-title class="font-weight-medium text-truncate">
|
||||
{{ entry.provider.id }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
|
||||
<span>{{ entry.provider.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1" @click.stop>
|
||||
<v-switch
|
||||
v-model="entry.provider.enable"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
color="primary"
|
||||
class="mr-1"
|
||||
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
|
||||
></v-switch>
|
||||
<v-tooltip location="top" max-width="300">
|
||||
{{ tm('availability.test') }}
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-wrench"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!entry.provider.enable"
|
||||
:loading="isProviderTesting(entry.provider.id)"
|
||||
v-bind="props"
|
||||
@click.stop="emit('test-provider', entry.provider)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-else class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
|
||||
<v-list-item-title>{{ entry.model }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
|
||||
<span>{{ entry.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
modelSearch: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loadingModels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSourceModified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
supportsImageInput: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsToolCall: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsReasoning: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
formatContextLimit: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
testingProviders: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelSearch',
|
||||
'fetch-models',
|
||||
'open-manual-model',
|
||||
'open-provider-edit',
|
||||
'toggle-provider-enable',
|
||||
'test-provider',
|
||||
'delete-provider',
|
||||
'add-model-provider'
|
||||
])
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
>
|
||||
新增
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedProviderSources.length > 0">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="source in displayedProviderSources"
|
||||
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
|
||||
:value="source.id"
|
||||
:active="isActive(source)"
|
||||
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
|
||||
rounded="lg"
|
||||
@click="emitSelectSource(source)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn
|
||||
v-if="!source.isPlaceholder"
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 px-4">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
displayedProviderSources: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedProviderSource: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
availableSourceTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
resolveSourceIcon: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getSourceDisplayName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add-provider-source',
|
||||
'select-provider-source',
|
||||
'delete-provider-source'
|
||||
])
|
||||
|
||||
const selectedId = computed(() => props.selectedProviderSource?.id || null)
|
||||
|
||||
const isActive = (source) => {
|
||||
if (source.isPlaceholder) return false
|
||||
return selectedId.value !== null && selectedId.value === source.id
|
||||
}
|
||||
|
||||
const emitAddSource = (type) => emit('add-provider-source', type)
|
||||
const emitSelectSource = (source) => emit('select-provider-source', source)
|
||||
const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-sources-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
max-height: calc(100vh - 335px);
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.provider-source-list-item {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-source-list-item--active {
|
||||
background-color: #E8F0FE;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-source-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.provider-sources-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -162,7 +162,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<!-- Regular Property -->
|
||||
<template v-else>
|
||||
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
|
||||
<v-col cols="12" sm="7" class="property-info">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
<span v-if="metadata[metadataKey].items[key]?.description">
|
||||
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" class="config-input">
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
||||
<!-- Special handling for specific metadata types -->
|
||||
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
|
||||
@@ -540,6 +540,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.important-hint {
|
||||
@@ -573,7 +574,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-row:hover {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between ga-2">
|
||||
<div v-if="isSingleItemMode" class="flex-grow-1 d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
v-model="singleItemValue"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="flex-grow-1"
|
||||
></v-text-field>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ t('core.common.list.noItems') }}
|
||||
</span>
|
||||
@@ -14,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ buttonText || t('core.common.list.modifyButton') }}
|
||||
{{ preferSingleItem ? '添加更多' : (buttonText || t('core.common.list.modifyButton')) }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +176,10 @@ const props = defineProps({
|
||||
maxDisplayItems: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
preferSingleItem: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,6 +193,21 @@ const editIndex = ref(-1)
|
||||
const editItem = ref('')
|
||||
const showBatchImport = ref(false)
|
||||
const batchImportText = ref('')
|
||||
const isSingleItemMode = computed(() => (props.modelValue?.length ?? 0) <= 1 && props.preferSingleItem)
|
||||
const singleItemValue = computed({
|
||||
get: () => props.modelValue?.[0] ?? '',
|
||||
set: (value) => {
|
||||
const newItems = [...(props.modelValue || [])]
|
||||
|
||||
if (newItems.length === 0) {
|
||||
newItems.push(value)
|
||||
} else {
|
||||
newItems[0] = value
|
||||
}
|
||||
|
||||
emit('update:modelValue', newItems)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算要显示的项目
|
||||
const displayItems = computed(() => {
|
||||
|
||||
@@ -14,8 +14,20 @@
|
||||
<!-- Provider Selection Dialog -->
|
||||
<v-dialog v-model="dialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||
{{ tm('providerSelector.dialogTitle') }}
|
||||
<v-card-title
|
||||
class="text-h3 py-4 d-flex align-center justify-space-between gap-4 flex-wrap"
|
||||
style="font-weight: normal;"
|
||||
>
|
||||
<span>{{ tm('providerSelector.dialogTitle') }}</span>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openProviderDrawer"
|
||||
>
|
||||
{{ tm('providerSelector.createProvider') }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@@ -51,7 +63,7 @@
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
|
||||
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
|
||||
<span v-if="provider.model">- {{ provider.model }}</span>
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
@@ -79,12 +91,33 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-overlay
|
||||
v-model="providerDrawer"
|
||||
class="provider-drawer-overlay"
|
||||
location="right"
|
||||
transition="slide-x-reverse-transition"
|
||||
:scrim="true"
|
||||
@click:outside="closeProviderDrawer"
|
||||
>
|
||||
<v-card class="provider-drawer-card" elevation="12">
|
||||
<div class="provider-drawer-header">
|
||||
<v-btn icon variant="text" @click="closeProviderDrawer">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="provider-drawer-content">
|
||||
<ProviderPage :default-tab="defaultTab" />
|
||||
</div>
|
||||
</v-card>
|
||||
</v-overlay>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import ProviderPage from '@/views/ProviderPage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -112,12 +145,26 @@ const dialog = ref(false)
|
||||
const providerList = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedProvider = ref('')
|
||||
const providerDrawer = ref(false)
|
||||
|
||||
const defaultTab = computed(() => {
|
||||
if (props.providerType === 'agent_runner' && props.providerSubtype) {
|
||||
return `select_agent_runner_provider:${props.providerSubtype}`
|
||||
}
|
||||
return props.providerType || 'chat_completion'
|
||||
})
|
||||
|
||||
// 监听 modelValue 变化,同步到 selectedProvider
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedProvider.value = newValue || ''
|
||||
}, { immediate: true })
|
||||
|
||||
watch(providerDrawer, (isOpen, wasOpen) => {
|
||||
if (!isOpen && wasOpen) {
|
||||
loadProviders()
|
||||
}
|
||||
})
|
||||
|
||||
async function openDialog() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
dialog.value = true
|
||||
@@ -170,6 +217,14 @@ function cancelSelection() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function openProviderDrawer() {
|
||||
providerDrawer.value = true
|
||||
}
|
||||
|
||||
function closeProviderDrawer() {
|
||||
providerDrawer.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -184,4 +239,35 @@ function cancelSelection() {
|
||||
.v-list-item.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.provider-drawer-overlay {
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.provider-drawer-card {
|
||||
width: clamp(360px, 70vw, 1200px);
|
||||
height: calc(100vh - 32px);
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px 20px;
|
||||
}
|
||||
|
||||
.provider-drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-drawer-content > * {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
tm: (key: string, params?: Record<string, unknown>) => string
|
||||
showMessage: (message: string, color?: string) => void
|
||||
}
|
||||
|
||||
export function resolveDefaultTab(value?: string) {
|
||||
const normalized = (value || '').toLowerCase()
|
||||
|
||||
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
|
||||
return 'agent_runner'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
|
||||
return 'speech_to_text'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_tts' || normalized === 'text_to_speech' || normalized.includes('tts')) {
|
||||
return 'text_to_speech'
|
||||
}
|
||||
|
||||
if (normalized.includes('embedding')) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (normalized.includes('rerank')) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
return 'chat_completion'
|
||||
}
|
||||
|
||||
export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
const { tm, showMessage } = options
|
||||
|
||||
// ===== State =====
|
||||
const config = ref<Record<string, any>>({})
|
||||
const metadata = ref<Record<string, any>>({})
|
||||
const providerSources = ref<any[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
const selectedProviderType = ref<string>(resolveDefaultTab(options.defaultTab))
|
||||
const selectedProviderSource = ref<any | null>(null)
|
||||
const selectedProviderSourceOriginalId = ref<string | null>(null)
|
||||
const editableProviderSource = ref<any | null>(null)
|
||||
const availableModels = ref<any[]>([])
|
||||
const modelMetadata = ref<Record<string, any>>({})
|
||||
const loadingModels = ref(false)
|
||||
const savingSource = ref(false)
|
||||
const testingProviders = ref<string[]>([])
|
||||
const isSourceModified = ref(false)
|
||||
const configSchema = ref<Record<string, any>>({})
|
||||
const providerTemplates = ref<Record<string, any>>({})
|
||||
const manualModelId = ref('')
|
||||
const modelSearch = ref('')
|
||||
|
||||
let suppressSourceWatch = false
|
||||
|
||||
const providerTypes = [
|
||||
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
|
||||
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
|
||||
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
|
||||
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
|
||||
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
|
||||
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
|
||||
]
|
||||
|
||||
// ===== Computed =====
|
||||
const availableSourceTypes = computed(() => {
|
||||
if (!providerTemplates.value || Object.keys(providerTemplates.value).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types: Array<{ value: string; label: string }> = []
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({ value: templateName, label: templateName })
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
|
||||
const filteredProviderSources = computed(() => {
|
||||
if (!providerSources.value) return []
|
||||
|
||||
return providerSources.value.filter((source) =>
|
||||
source.provider_type === selectedProviderType.value ||
|
||||
(source.type && isTypeMatchingProviderType(source.type, selectedProviderType.value))
|
||||
)
|
||||
})
|
||||
|
||||
const displayedProviderSources = computed(() => {
|
||||
const existing = filteredProviderSources.value || []
|
||||
const existingProviders = new Set(existing.map((src: any) => src.provider).filter(Boolean))
|
||||
const placeholders: any[] = []
|
||||
|
||||
if (providerTemplates.value && Object.keys(providerTemplates.value).length > 0) {
|
||||
for (const [templateKey, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type !== selectedProviderType.value) continue
|
||||
if (!template.provider) continue
|
||||
if (existingProviders.has(template.provider)) continue
|
||||
|
||||
placeholders.push({
|
||||
id: template.id || templateKey,
|
||||
provider: template.provider,
|
||||
provider_type: template.provider_type,
|
||||
type: template.type,
|
||||
api_base: template.api_base || '',
|
||||
templateKey,
|
||||
isPlaceholder: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...existing, ...placeholders]
|
||||
})
|
||||
|
||||
const sourceProviders = computed(() => {
|
||||
if (!selectedProviderSource.value || !providers.value) return []
|
||||
|
||||
return providers.value.filter((p) => p.provider_source_id === selectedProviderSource.value.id)
|
||||
})
|
||||
|
||||
const existingModelsForSelectedSource = computed(() => {
|
||||
if (!selectedProviderSource.value) return new Set<string>()
|
||||
return new Set(sourceProviders.value.map((p: any) => p.model))
|
||||
})
|
||||
|
||||
const sortedAvailableModels = computed(() => {
|
||||
const existing = existingModelsForSelectedSource.value
|
||||
return [...(availableModels.value || [])].sort((a, b) => {
|
||||
const aName = typeof a === 'string' ? a : a?.name
|
||||
const bName = typeof b === 'string' ? b : b?.name
|
||||
const aExists = existing.has(aName)
|
||||
const bExists = existing.has(bName)
|
||||
if (aExists && !bExists) return -1
|
||||
if (!aExists && bExists) return 1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const mergedModelEntries = computed(() => {
|
||||
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
|
||||
type: 'configured',
|
||||
provider,
|
||||
metadata: getModelMetadata(provider.model)
|
||||
}))
|
||||
|
||||
const availableEntries = (sortedAvailableModels.value || [])
|
||||
.filter((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return !existingModelsForSelectedSource.value.has(name)
|
||||
})
|
||||
.map((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return {
|
||||
type: 'available',
|
||||
model: name,
|
||||
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
|
||||
}
|
||||
})
|
||||
|
||||
return [...configuredEntries, ...availableEntries]
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
if (entry.type === 'configured') {
|
||||
const id = entry.provider.id?.toLowerCase() || ''
|
||||
const model = entry.provider.model?.toLowerCase() || ''
|
||||
return id.includes(term) || model.includes(term)
|
||||
}
|
||||
|
||||
const model = entry.model?.toLowerCase() || ''
|
||||
return model.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const manualProviderId = computed(() => {
|
||||
if (!selectedProviderSource.value) return ''
|
||||
const modelId = manualModelId.value.trim()
|
||||
if (!modelId) return ''
|
||||
return `${selectedProviderSource.value.id}/${modelId}`
|
||||
})
|
||||
|
||||
const basicSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const fields = ['id', 'key', 'api_base']
|
||||
const basic: Record<string, any> = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
Object.defineProperty(basic, field, {
|
||||
get() {
|
||||
return editableProviderSource.value![field]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![field] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
})
|
||||
|
||||
return basic
|
||||
})
|
||||
|
||||
const advancedSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']
|
||||
const advanced: Record<string, any> = {}
|
||||
|
||||
for (const key of Object.keys(editableProviderSource.value)) {
|
||||
if (excluded.includes(key)) continue
|
||||
Object.defineProperty(advanced, key, {
|
||||
get() {
|
||||
return editableProviderSource.value![key]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![key] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!providers.value || selectedProviderType.value === 'chat_completion') {
|
||||
return []
|
||||
}
|
||||
|
||||
return providers.value.filter((provider: any) => getProviderType(provider) === selectedProviderType.value)
|
||||
})
|
||||
|
||||
// ===== Watches =====
|
||||
watch(editableProviderSource, () => {
|
||||
if (suppressSourceWatch) return
|
||||
if (!editableProviderSource.value) return
|
||||
isSourceModified.value = true
|
||||
}, { deep: true })
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function isTypeMatchingProviderType(type?: string, providerType?: string) {
|
||||
if (!type || !providerType) return false
|
||||
if (providerType === 'chat_completion') {
|
||||
return type.includes('chat_completion')
|
||||
}
|
||||
return type.includes(providerType)
|
||||
}
|
||||
|
||||
function resolveSourceIcon(source: any) {
|
||||
if (!source) return ''
|
||||
return getProviderIcon(source.provider) || ''
|
||||
}
|
||||
|
||||
function getSourceDisplayName(source: any) {
|
||||
if (!source) return ''
|
||||
if (source.isPlaceholder) return source.templateKey || source.id || ''
|
||||
return source.id
|
||||
}
|
||||
|
||||
function getModelMetadata(modelName?: string) {
|
||||
if (!modelName) return null
|
||||
return modelMetadata.value?.[modelName] || null
|
||||
}
|
||||
|
||||
function supportsImageInput(meta: any) {
|
||||
const inputs = meta?.modalities?.input || []
|
||||
return inputs.includes('image')
|
||||
}
|
||||
|
||||
function supportsToolCall(meta: any) {
|
||||
return Boolean(meta?.tool_call)
|
||||
}
|
||||
|
||||
function supportsReasoning(meta: any) {
|
||||
return Boolean(meta?.reasoning)
|
||||
}
|
||||
|
||||
function formatContextLimit(meta: any) {
|
||||
const ctx = meta?.limit?.context
|
||||
if (!ctx || typeof ctx !== 'number') return ''
|
||||
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
|
||||
if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`
|
||||
return `${ctx}`
|
||||
}
|
||||
|
||||
function getProviderType(provider: any) {
|
||||
if (!provider) return undefined
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type
|
||||
}
|
||||
|
||||
const oldVersionProviderTypeMapping: Record<string, string> = {
|
||||
openai_chat_completion: 'chat_completion',
|
||||
anthropic_chat_completion: 'chat_completion',
|
||||
googlegenai_chat_completion: 'chat_completion',
|
||||
zhipu_chat_completion: 'chat_completion',
|
||||
dify: 'agent_runner',
|
||||
coze: 'agent_runner',
|
||||
dashscope: 'chat_completion',
|
||||
openai_whisper_api: 'speech_to_text',
|
||||
openai_whisper_selfhost: 'speech_to_text',
|
||||
sensevoice_stt_selfhost: 'speech_to_text',
|
||||
openai_tts_api: 'text_to_speech',
|
||||
edge_tts: 'text_to_speech',
|
||||
gsvi_tts_api: 'text_to_speech',
|
||||
fishaudio_tts_api: 'text_to_speech',
|
||||
dashscope_tts: 'text_to_speech',
|
||||
azure_tts: 'text_to_speech',
|
||||
minimax_tts_api: 'text_to_speech',
|
||||
volcengine_tts: 'text_to_speech'
|
||||
}
|
||||
return oldVersionProviderTypeMapping[provider.type]
|
||||
}
|
||||
|
||||
function selectProviderSource(source: any) {
|
||||
if (source?.isPlaceholder && source.templateKey) {
|
||||
addProviderSource(source.templateKey)
|
||||
return
|
||||
}
|
||||
|
||||
selectedProviderSource.value = source
|
||||
selectedProviderSourceOriginalId.value = source?.id || null
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = false
|
||||
}
|
||||
|
||||
function extractSourceFieldsFromTemplate(template: Record<string, any>) {
|
||||
const sourceFields: Record<string, any> = {}
|
||||
const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body']
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
if (!excludeKeys.includes(key)) {
|
||||
sourceFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFields
|
||||
}
|
||||
|
||||
function generateUniqueSourceId(baseId: string) {
|
||||
const existingIds = new Set(providerSources.value.map((s: any) => s.id))
|
||||
if (!existingIds.has(baseId)) return baseId
|
||||
|
||||
let counter = 1
|
||||
let candidate = `${baseId}_${counter}`
|
||||
while (existingIds.has(candidate)) {
|
||||
counter += 1
|
||||
candidate = `${baseId}_${counter}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function addProviderSource(templateKey: string) {
|
||||
const template = providerTemplates.value[templateKey]
|
||||
if (!template) {
|
||||
showMessage('未找到对应的模板配置', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const newId = generateUniqueSourceId(template.id)
|
||||
const newSource = {
|
||||
...extractSourceFieldsFromTemplate(template),
|
||||
id: newId,
|
||||
type: template.type,
|
||||
provider_type: template.provider_type,
|
||||
provider: template.provider,
|
||||
enable: true
|
||||
}
|
||||
|
||||
providerSources.value.push(newSource)
|
||||
selectedProviderSource.value = newSource
|
||||
selectedProviderSourceOriginalId.value = newId
|
||||
editableProviderSource.value = JSON.parse(JSON.stringify(newSource))
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = true
|
||||
}
|
||||
|
||||
async function deleteProviderSource(source: any) {
|
||||
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post(`/api/config/provider_sources/${source.id}/delete`)
|
||||
|
||||
providers.value = providers.value.filter((p) => p.provider_source_id !== source.id)
|
||||
providerSources.value = providerSources.value.filter((s) => s.id !== source.id)
|
||||
|
||||
if (selectedProviderSource.value?.id === source.id) {
|
||||
selectedProviderSource.value = null
|
||||
selectedProviderSourceOriginalId.value = null
|
||||
editableProviderSource.value = null
|
||||
}
|
||||
|
||||
showMessage(tm('providerSources.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('providerSources.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProviderSource() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
savingSource.value = true
|
||||
const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id
|
||||
try {
|
||||
const response = await axios.post(`/api/config/provider_sources/${originalId}/update`, {
|
||||
config: editableProviderSource.value,
|
||||
original_id: originalId
|
||||
})
|
||||
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
|
||||
if (editableProviderSource.value!.id !== originalId) {
|
||||
providers.value = providers.value.map((p) =>
|
||||
p.provider_source_id === originalId
|
||||
? { ...p, provider_source_id: editableProviderSource.value!.id }
|
||||
: p
|
||||
)
|
||||
selectedProviderSourceOriginalId.value = editableProviderSource.value!.id
|
||||
}
|
||||
|
||||
const idx = providerSources.value.findIndex((ps) => ps.id === originalId)
|
||||
if (idx !== -1) {
|
||||
providerSources.value[idx] = JSON.parse(JSON.stringify(editableProviderSource.value))
|
||||
selectedProviderSource.value = providerSources.value[idx]
|
||||
}
|
||||
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = selectedProviderSource.value
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
|
||||
isSourceModified.value = false
|
||||
showMessage(response.data.message || tm('providerSources.saveSuccess'))
|
||||
return true
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
if (isSourceModified.value) {
|
||||
const saved = await saveProviderSource()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const response = await axios.get(`/api/config/provider_sources/${sourceId}/models`)
|
||||
if (response.data.status === 'ok') {
|
||||
const metadataMap = response.data.data.model_metadata || {}
|
||||
modelMetadata.value = metadataMap
|
||||
availableModels.value = (response.data.data.models || []).map((model: string) => ({
|
||||
name: model,
|
||||
metadata: metadataMap?.[model] || null
|
||||
}))
|
||||
if (availableModels.value.length === 0) {
|
||||
showMessage(tm('models.noModelsFound'), 'info')
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
} catch (error: any) {
|
||||
modelMetadata.value = {}
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addModelProvider(modelName: string) {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const newId = `${sourceId}/${modelName}`
|
||||
|
||||
const modalities = ['text']
|
||||
if (supportsImageInput(getModelMetadata(modelName))) {
|
||||
modalities.push('image')
|
||||
}
|
||||
if (supportsToolCall(getModelMetadata(modelName))) {
|
||||
modalities.push('tool_use')
|
||||
}
|
||||
|
||||
const newProvider = {
|
||||
id: newId,
|
||||
enable: false,
|
||||
provider_source_id: sourceId,
|
||||
model: modelName,
|
||||
modalities,
|
||||
custom_extra_body: {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/new', newProvider)
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
providers.value.push(newProvider)
|
||||
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function modelAlreadyConfigured(modelName: string) {
|
||||
return existingModelsForSelectedSource.value.has(modelName)
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: any) {
|
||||
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider/delete', { id: provider.id })
|
||||
providers.value = providers.value.filter((p) => p.id !== provider.id)
|
||||
showMessage(tm('models.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('models.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.testError'), 'error')
|
||||
} finally {
|
||||
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loadProviderTemplate()
|
||||
}
|
||||
|
||||
async function loadProviderTemplate() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/template')
|
||||
if (response.data.status === 'ok') {
|
||||
configSchema.value = response.data.data.config_schema || {}
|
||||
if (configSchema.value.provider?.config_template) {
|
||||
providerTemplates.value = configSchema.value.provider.config_template
|
||||
}
|
||||
providerSources.value = response.data.data.provider_sources || []
|
||||
providers.value = response.data.data.providers || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load provider template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDefaultTab(value: string) {
|
||||
selectedProviderType.value = resolveDefaultTab(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProviderTemplate()
|
||||
})
|
||||
|
||||
return {
|
||||
// state
|
||||
config,
|
||||
metadata,
|
||||
providerSources,
|
||||
providers,
|
||||
selectedProviderType,
|
||||
selectedProviderSource,
|
||||
selectedProviderSourceOriginalId,
|
||||
editableProviderSource,
|
||||
availableModels,
|
||||
modelMetadata,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
testingProviders,
|
||||
isSourceModified,
|
||||
configSchema,
|
||||
providerTemplates,
|
||||
manualModelId,
|
||||
modelSearch,
|
||||
|
||||
// computed
|
||||
providerTypes,
|
||||
availableSourceTypes,
|
||||
displayedProviderSources,
|
||||
sourceProviders,
|
||||
mergedModelEntries,
|
||||
filteredMergedModelEntries,
|
||||
filteredProviders,
|
||||
basicSourceConfig,
|
||||
advancedSourceConfig,
|
||||
manualProviderId,
|
||||
|
||||
// helpers
|
||||
resolveSourceIcon,
|
||||
getSourceDisplayName,
|
||||
getModelMetadata,
|
||||
supportsImageInput,
|
||||
supportsToolCall,
|
||||
supportsReasoning,
|
||||
formatContextLimit,
|
||||
getProviderType,
|
||||
|
||||
// methods
|
||||
updateDefaultTab,
|
||||
selectProviderSource,
|
||||
addProviderSource,
|
||||
deleteProviderSource,
|
||||
saveProviderSource,
|
||||
fetchAvailableModels,
|
||||
addModelProvider,
|
||||
deleteProvider,
|
||||
modelAlreadyConfigured,
|
||||
testProvider,
|
||||
loadConfig,
|
||||
loadProviderTemplate
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@
|
||||
"cancelSelection": "Cancel",
|
||||
"clearSelection": "None",
|
||||
"clearSelectionSubtitle": "Clear current selection",
|
||||
"unknownType": "Unknown type"
|
||||
"unknownType": "Unknown type",
|
||||
"createProvider": "Create Provider",
|
||||
"manageProviders": "Provider Management"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Service Provider Management",
|
||||
"subtitle": "Manage model service providers",
|
||||
"title": "Providers",
|
||||
"subtitle": "Manage model providers",
|
||||
"providers": {
|
||||
"title": "Service Providers",
|
||||
"settings": "Settings",
|
||||
@@ -85,5 +85,50 @@
|
||||
"confirm": {
|
||||
"delete": "Are you sure you want to delete service provider {id}?"
|
||||
}
|
||||
},
|
||||
"providerTypes": {
|
||||
"title": "Provider Types"
|
||||
},
|
||||
"providerSources": {
|
||||
"title": "Provider Sources",
|
||||
"empty": "No provider sources",
|
||||
"selectHint": "Please select a provider source",
|
||||
"save": "Save Configuration",
|
||||
"saveAndFetchModels": "Save and Fetch Models",
|
||||
"fetchModels": "Fetch Model List",
|
||||
"saveSuccess": "Provider source saved successfully",
|
||||
"saveError": "Failed to save provider source",
|
||||
"deleteConfirm": "Are you sure you want to delete provider source {id}? This will also delete all associated model configurations.",
|
||||
"deleteSuccess": "Provider source deleted successfully",
|
||||
"deleteError": "Failed to delete provider source",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"advancedConfig": "Advanced Configuration...",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"apiKey": "API Key",
|
||||
"baseUrl": "Base URL"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"available": "Available Models",
|
||||
"configured": "Configured Models",
|
||||
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
|
||||
"noModelsFound": "No available models found",
|
||||
"fetchError": "Failed to fetch models",
|
||||
"addSuccess": "Model {model} added successfully",
|
||||
"deleteConfirm": "Are you sure you want to delete model {id}?",
|
||||
"deleteSuccess": "Model deleted successfully",
|
||||
"deleteError": "Failed to delete model",
|
||||
"testSuccess": "Model {id} test passed",
|
||||
"testError": "Model test failed",
|
||||
"searchPlaceholder": "Search models or ID",
|
||||
"manualAddButton": "Custom Model",
|
||||
"manualDialogTitle": "Add Custom Model",
|
||||
"manualDialogModelLabel": "Model ID (e.g. gpt-4.1-mini)",
|
||||
"manualDialogPreviewLabel": "Display ID (auto generated)",
|
||||
"manualDialogPreviewHint": "Generated as sourceId/modelId",
|
||||
"manualModelRequired": "Please enter a model ID",
|
||||
"manualModelExists": "Model already exists"
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@
|
||||
"cancelSelection": "取消",
|
||||
"clearSelection": "不选择",
|
||||
"clearSelectionSubtitle": "清除当前选择",
|
||||
"unknownType": "未知类型"
|
||||
"unknownType": "未知类型",
|
||||
"createProvider": "创建提供商",
|
||||
"manageProviders": "提供商管理"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,5 +86,50 @@
|
||||
"confirm": {
|
||||
"delete": "确定要删除模型提供商 {id} 吗?"
|
||||
}
|
||||
},
|
||||
"providerTypes": {
|
||||
"title": "提供商类型"
|
||||
},
|
||||
"providerSources": {
|
||||
"title": "提供商源",
|
||||
"empty": "暂无提供商源",
|
||||
"selectHint": "请选择一个提供商源",
|
||||
"save": "保存配置",
|
||||
"saveAndFetchModels": "保存并获取模型",
|
||||
"fetchModels": "获取模型列表",
|
||||
"saveSuccess": "提供商源保存成功",
|
||||
"saveError": "提供商源保存失败",
|
||||
"deleteConfirm": "确定要删除提供商源 {id} 吗?这将同时删除关联的所有模型配置。",
|
||||
"deleteSuccess": "提供商源删除成功",
|
||||
"deleteError": "提供商源删除失败",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"advancedConfig": "高级配置...",
|
||||
"fields": {
|
||||
"name": "名称",
|
||||
"apiKey": "API Key",
|
||||
"baseUrl": "Base URL"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"available": "可用模型",
|
||||
"configured": "已配置的模型",
|
||||
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
|
||||
"noModelsFound": "未找到可用模型",
|
||||
"fetchError": "获取模型列表失败",
|
||||
"addSuccess": "模型 {model} 添加成功",
|
||||
"deleteConfirm": "确定要删除模型 {id} 吗?",
|
||||
"deleteSuccess": "模型删除成功",
|
||||
"deleteError": "模型删除失败",
|
||||
"testSuccess": "模型 {id} 测试通过",
|
||||
"testError": "模型测试失败",
|
||||
"searchPlaceholder": "搜索模型或 ID",
|
||||
"manualAddButton": "自定义模型",
|
||||
"manualDialogTitle": "添加自定义模型",
|
||||
"manualDialogModelLabel": "模型 ID(如 gpt-4.1-mini)",
|
||||
"manualDialogPreviewLabel": "显示 ID(自动生成)",
|
||||
"manualDialogPreviewHint": "生成规则:源ID/模型ID",
|
||||
"manualModelRequired": "请输入模型 ID",
|
||||
"manualModelExists": "该模型已存在"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
.v-input--density-default,
|
||||
.v-field--variant-solo,
|
||||
.v-field--variant-filled {
|
||||
--v-input-control-height: 51px;
|
||||
--v-input-padding-top: 14px;
|
||||
}
|
||||
.v-input--density-comfortable {
|
||||
--v-input-control-height: 56px;
|
||||
--v-input-padding-top: 17px;
|
||||
}
|
||||
.v-label {
|
||||
font-size: 0.975rem;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,3 @@
|
||||
.v-text-field input {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.v-input--density-default {
|
||||
.v-field__input {
|
||||
min-height: 51px;
|
||||
}
|
||||
}
|
||||
|
||||
.v-field__outline {
|
||||
color: rgb(var(--v-theme-inputBorder));
|
||||
}
|
||||
|
||||
// 亮色主题样式
|
||||
.v-theme--PurpleTheme .v-field__outline {
|
||||
--v-field-border-width: 1.2px !important;
|
||||
--v-field-border-opacity: 1 !important;
|
||||
border-color: #d1cfcf;
|
||||
}
|
||||
|
||||
.v-theme--PurpleTheme .v-text-field .v-field--focused .v-field__outline {
|
||||
--v-field-border-width: 2px !important;
|
||||
--v-field-border-opacity: 1 !important;
|
||||
}
|
||||
|
||||
// 深色主题样式
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field__outline {
|
||||
--v-field-border-width: 2px !important;
|
||||
--v-field-border-opacity: 1 !important;
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
border-color: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field:hover .v-field__outline {
|
||||
--v-field-border-width: 2px !important;
|
||||
--v-field-border-opacity: 1 !important;
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
border-color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field--focused .v-field__outline {
|
||||
--v-field-border-width: 2.5px !important;
|
||||
--v-field-border-opacity: 1 !important;
|
||||
color: rgb(129, 102, 176) !important;
|
||||
border-color: rgb(126, 99, 171) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field input {
|
||||
color: #ffffff !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field__label {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field__prepend-inner .v-icon,
|
||||
.v-theme--PurpleThemeDark .v-text-field .v-field__append-inner .v-icon {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.inputWithbg {
|
||||
.v-field--variant-outlined {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -19,24 +19,6 @@
|
||||
top: -85px;
|
||||
right: -95px;
|
||||
}
|
||||
|
||||
// &.bubble-primary-shape {
|
||||
// &::before {
|
||||
// background: rgb(var(--v-theme-darkprimary));
|
||||
// }
|
||||
// &::after {
|
||||
// background: rgb(var(--v-theme-darkprimary));
|
||||
// }
|
||||
// }
|
||||
|
||||
// &.bubble-secondary-shape {
|
||||
// &::before {
|
||||
// background: rgb(var(--v-theme-darksecondary));
|
||||
// }
|
||||
// &::after {
|
||||
// background: rgb(var(--v-theme-darksecondary));
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
.z-1 {
|
||||
@@ -54,11 +36,6 @@
|
||||
top: -160px;
|
||||
right: -130px;
|
||||
}
|
||||
// &.bubble-primary {
|
||||
// &::before {
|
||||
// background: linear-gradient(140.9deg, rgb(var(--v-theme-lightprimary)) -14.02%, rgba(var(--v-theme-darkprimary), 0) 77.58%);
|
||||
// }
|
||||
// }
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -68,23 +45,6 @@
|
||||
top: -30px;
|
||||
right: -180px;
|
||||
}
|
||||
// &.bubble-primary {
|
||||
// &::after {
|
||||
// background: linear-gradient(210.04deg, rgb(var(--v-theme-lightprimary)) -50.94%, rgba(var(--v-theme-darkprimary), 0) 83.49%);
|
||||
// }
|
||||
// }
|
||||
|
||||
// &.bubble-warning {
|
||||
// &::before {
|
||||
// background: linear-gradient(140.9deg, rgb(var(--v-theme-warning)) -14.02%, rgba(144, 202, 249, 0) 70.5%);
|
||||
// }
|
||||
// }
|
||||
|
||||
// &.bubble-warning {
|
||||
// &::after {
|
||||
// background: linear-gradient(210.04deg, rgb(var(--v-theme-warning)) -50.94%, rgba(144, 202, 249, 0) 83.49%);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
.rounded-square {
|
||||
|
||||
@@ -32,6 +32,9 @@ export function getProviderIcon(type) {
|
||||
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
|
||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
||||
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
|
||||
"modelstack": new URL('@/assets/images/provider_logos/modelstack.svg', import.meta.url).href,
|
||||
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
|
||||
"compshare": "https://compshare.cn/favicon.ico"
|
||||
};
|
||||
return icons[type] || '';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user