Merge pull request #4065 from AstrBotDevs/refactor/provider-source

refactor: SUPER AMAZING model provider refactor
This commit is contained in:
Soulter
2025-12-20 00:09:36 +08:00
committed by GitHub
32 changed files with 2818 additions and 1597 deletions
+138 -204
View File
@@ -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 和 cudaCPU 用户大约下载 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",
+3
View File
@@ -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() 方法
+203 -93
View File
@@ -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 idvalue 为合并后的配置字典
"""
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)
+4 -4
View File
@@ -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
+63
View File
@@ -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
+93
View File
@@ -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())
+291 -33
View File
@@ -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

+6 -4
View File
@@ -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 -68
View File
@@ -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;
}
-40
View File
@@ -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 {
+3
View File
@@ -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