stage
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
总览:对于 chat_completion 的提供商,目前需要将 provider 分成 provider_source 和 provider 两个部分,这样可以更好地支持同一 provider_source 下的多模型添加。
|
||||
|
||||
目前已经在 astrbot/core/config/default.py 中对 provider 配置进行了拆分,接下来需要修改:
|
||||
|
||||
## provider/manager 部分:
|
||||
|
||||
需要传入 provider_sources 给 ProviderManager,然后在创建 provider 的时候:
|
||||
|
||||
1. 如果 provider.provider_source_id 存在且不为空,则从 provider_sources 中找到对应的 provider_source,并将 provider_source 的配置合并到 provider_config 中。provider 的配置优先级更高。
|
||||
2. 如果 provider.provider_source_id 不存在或为空,则使用 provider 自身的配置。
|
||||
|
||||
## WebUI 部分:
|
||||
|
||||
我们主要需要大量修改 ProviderPage.vue 文件。
|
||||
|
||||
将整体页面换成左右多栏布局。
|
||||
|
||||
分为 三个 栏目:
|
||||
|
||||
1. 左第一个栏目用于选择 provider type。
|
||||
2. 中间第二个栏目用于选择 provider source。
|
||||
3. 右第三个栏目用于配置 provider。
|
||||
|
||||
中间的栏目最上面有一个添加按钮,点击后会出现一个 dropdown,列出所有的 provider source type,选择后会在中间栏目添加一个 provider source。
|
||||
|
||||
选中某个 provider source 后,右侧栏目会显示该 provider source 的配置。指向这个 provider source 的 provider 本质上是 model 的配置。在 provider source 配置下面,会有一个“获取模型”的按钮(如果没有保存/更新 source 配置,则写“保存并获取模型”),点击后会在下方有一个 v-list 列出该 provider source 支持的所有模型,选择后会在下方增加一个新的模型配置(也就是provider的配置)。整个过程从设计上看不要有任何 dialog。
|
||||
|
||||
有一个保存按钮,用于保存当前 provider source 和 provider 的配置。
|
||||
|
||||
### 模型列表的获取
|
||||
|
||||
需要增加一个 API,用于获取某个 provider source 支持的模型列表。前端在点击“添加模型”按钮时,调用该 API 获取模型列表并展示在 dropdown 中。
|
||||
|
||||
GET /config/provider_sources/<provider_source_id>/models
|
||||
|
||||
本质上这个会初始化一下 Provider,然后调用 Provider.get_models() 来获取模型列表,然后销毁 Provider 实例。
|
||||
|
||||
### 测试模型
|
||||
|
||||
在 provider 的配置那里,增加一个“测试模型”按钮,类同现在的测试功能。
|
||||
|
||||
## 迁移
|
||||
|
||||
需要编写一个迁移脚本,将现有的 provider 配置拆分成 provider_source 和 provider 两部分,provider_source 的配置从现有的 provider 中提取公共部分,provider 则只保留模型相关的配置。
|
||||
|
||||
仅对 chat_completion 类型的 provider 进行迁移,其他类型的 provider 保持不变。
|
||||
|
||||
可以在 astrbot/core/utils/migra_helper.py 的 migra 方法中添加迁移逻辑。
|
||||
|
||||
## UI 风格
|
||||
|
||||
整体风格需要现代化、简洁化,不要使用任何渐变。
|
||||
|
||||
## 其他
|
||||
|
||||
请最小化改动。
|
||||
@@ -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": "",
|
||||
@@ -170,6 +172,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 时代的配置元数据,目前仅承担以下功能:
|
||||
|
||||
@@ -843,6 +861,7 @@ CONFIG_METADATA_2 = {
|
||||
"metadata": {
|
||||
"provider": {
|
||||
"type": "list",
|
||||
# provider sources templates
|
||||
"config_template": {
|
||||
"OpenAI": {
|
||||
"id": "openai",
|
||||
@@ -853,10 +872,7 @@ 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": {
|
||||
@@ -869,10 +885,7 @@ CONFIG_METADATA_2 = {
|
||||
"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",
|
||||
@@ -883,11 +896,8 @@ CONFIG_METADATA_2 = {
|
||||
"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,超出可能导致报错",
|
||||
@@ -899,12 +909,6 @@ CONFIG_METADATA_2 = {
|
||||
"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",
|
||||
@@ -915,10 +919,7 @@ CONFIG_METADATA_2 = {
|
||||
"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",
|
||||
@@ -928,12 +929,7 @@ CONFIG_METADATA_2 = {
|
||||
"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",
|
||||
@@ -944,13 +940,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-1.5-flash",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Gemini": {
|
||||
"id": "gemini_default",
|
||||
@@ -961,10 +951,6 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
@@ -978,7 +964,6 @@ CONFIG_METADATA_2 = {
|
||||
"gm_thinking_config": {
|
||||
"budget": 0,
|
||||
},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"DeepSeek": {
|
||||
"id": "deepseek_default",
|
||||
@@ -989,10 +974,7 @@ 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"],
|
||||
},
|
||||
"Groq": {
|
||||
"id": "groq_default",
|
||||
@@ -1003,13 +985,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",
|
||||
@@ -1020,10 +996,7 @@ 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"],
|
||||
},
|
||||
"硅基流动": {
|
||||
"id": "siliconflow",
|
||||
@@ -1034,13 +1007,7 @@ 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派欧云": {
|
||||
"id": "ppio",
|
||||
@@ -1051,12 +1018,7 @@ 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": {},
|
||||
},
|
||||
"小马算力": {
|
||||
"id": "tokenpony",
|
||||
@@ -1067,12 +1029,7 @@ 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": {},
|
||||
},
|
||||
"优云智算": {
|
||||
"id": "compshare",
|
||||
@@ -1083,12 +1040,7 @@ 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",
|
||||
@@ -1099,10 +1051,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"智谱 AI": {
|
||||
"id": "zhipu_default",
|
||||
@@ -1113,12 +1062,18 @@ CONFIG_METADATA_2 = {
|
||||
"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"],
|
||||
},
|
||||
"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",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Dify": {
|
||||
"id": "dify_app_default",
|
||||
@@ -1164,20 +1119,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",
|
||||
@@ -1448,6 +1389,10 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"provider_source_id": {
|
||||
"invisible": True,
|
||||
"type": "string",
|
||||
},
|
||||
"xai_native_search": {
|
||||
"description": "启用原生搜索功能",
|
||||
"type": "bool",
|
||||
@@ -2005,7 +1950,6 @@ CONFIG_METADATA_2 = {
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string",
|
||||
"hint": "模型提供商名字。",
|
||||
},
|
||||
"type": {
|
||||
"description": "模型提供商种类",
|
||||
@@ -2025,12 +1969,10 @@ 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": "模型配置",
|
||||
|
||||
@@ -36,6 +36,7 @@ class ProviderManager:
|
||||
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", {})
|
||||
@@ -252,6 +253,23 @@ class ProviderManager:
|
||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
||||
|
||||
async def load_provider(self, provider_config: dict):
|
||||
|
||||
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
|
||||
provider_source_id = provider_config.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, **provider_config}
|
||||
# 保持 id 为 provider 的 id,而不是 source 的 id
|
||||
merged_config["id"] = provider_config["id"]
|
||||
provider_config = merged_config
|
||||
|
||||
if not provider_config["enable"]:
|
||||
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
|
||||
return
|
||||
@@ -499,6 +517,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()):
|
||||
|
||||
@@ -112,7 +112,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}")
|
||||
@@ -152,7 +156,11 @@ class ProviderAnthropic(Provider):
|
||||
final_text = ""
|
||||
final_tool_calls = []
|
||||
|
||||
async with self.client.messages.stream(**payloads) as stream:
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
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 == "content_block_start":
|
||||
|
||||
@@ -32,6 +32,91 @@ 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",
|
||||
}
|
||||
|
||||
# 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 not ("chat_completion" 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 +156,10 @@ async def migra(
|
||||
|
||||
for conf in acm.confs.values():
|
||||
_migra_agent_runner_configs(conf, ids_map)
|
||||
|
||||
# Migrate providers to new structure: extract source fields to provider_sources
|
||||
try:
|
||||
_migra_provider_to_source_structure(astrbot_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Migration for provider-source structure failed: {e!s}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import file_token_service, logger
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import (
|
||||
CONFIG_METADATA_2,
|
||||
@@ -179,13 +179,29 @@ 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,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def get_provider_template(self):
|
||||
provider_config = astrbot_config["provider"]
|
||||
config_schema = {
|
||||
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
|
||||
}
|
||||
data = {
|
||||
"config_schema": config_schema,
|
||||
"providers": provider_config,
|
||||
}
|
||||
return Response().ok(data=data).__dict__
|
||||
|
||||
async def get_uc_table(self):
|
||||
"""获取 UMOP 配置路由表"""
|
||||
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
|
||||
@@ -522,6 +538,81 @@ 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__
|
||||
|
||||
# 获取对应的 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()
|
||||
|
||||
# 销毁实例(如果有 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}).__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 = []
|
||||
|
||||
@@ -508,6 +508,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.important-hint {
|
||||
|
||||
@@ -85,5 +85,42 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -86,5 +86,42 @@
|
||||
"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": "模型测试失败"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,892 @@
|
||||
<template>
|
||||
<div class="provider-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
|
||||
<div>
|
||||
<h1 class="text-h1 font-weight-bold mb-2">
|
||||
<v-icon color="black" class="me-2">mdi-creation</v-icon>{{ tm('title') }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
{{ tm('subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
|
||||
rounded="xl" size="x-large">
|
||||
{{ tm('providers.addProvider') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-row>
|
||||
|
||||
<div>
|
||||
<!-- 添加分类标签页 -->
|
||||
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent" class="mb-4">
|
||||
<v-tab value="all" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-filter-variant</v-icon>
|
||||
{{ tm('providers.tabs.all') }}
|
||||
</v-tab>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('providers.tabs.chatCompletion') }}
|
||||
</v-tab>
|
||||
<v-tab value="agent_runner" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('providers.tabs.agentRunner') }}
|
||||
</v-tab>
|
||||
<v-tab value="speech_to_text" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-microphone-message</v-icon>
|
||||
{{ tm('providers.tabs.speechToText') }}
|
||||
</v-tab>
|
||||
<v-tab value="text_to_speech" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-volume-high</v-icon>
|
||||
{{ tm('providers.tabs.textToSpeech') }}
|
||||
</v-tab>
|
||||
<v-tab value="embedding" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-code-json</v-icon>
|
||||
{{ tm('providers.tabs.embedding') }}
|
||||
</v-tab>
|
||||
<v-tab value="rerank" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-compare-vertical</v-icon>
|
||||
{{ tm('providers.tabs.rerank') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<template v-if="activeProviderTypeTab === 'all'">
|
||||
<v-row v-if="groupedProviders.length === 0">
|
||||
<v-col cols="12" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-4">{{ getEmptyText() }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div v-else>
|
||||
<div v-for="group in groupedProviders" :key="group.typeKey" class="mb-8">
|
||||
<h1 class="text-h3 font-weight-bold mb-4">{{ group.label }}</h1>
|
||||
<v-row>
|
||||
<v-col v-for="(provider, index) in group.items" :key="`${group.typeKey}-${index}`" cols="12" md="6"
|
||||
lg="4" xl="3">
|
||||
<item-card :item="provider" title-field="id" enabled-field="enable"
|
||||
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
<template #item-details="{ item }">
|
||||
<!-- 测试状态 chip -->
|
||||
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
|
||||
<v-icon start size="small">
|
||||
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
|
||||
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
|
||||
'mdi-clock-outline' }}
|
||||
</v-icon>
|
||||
{{ getStatusText(getProviderStatus(item.id).status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<span v-if="getProviderStatus(item.id).status === 'unavailable'">
|
||||
{{ getProviderStatus(item.id).error }}
|
||||
</span>
|
||||
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
|
||||
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
|
||||
{{ tm('availability.test') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:details="{ item }">
|
||||
</template>
|
||||
</item-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-row v-if="filteredProviders.length === 0">
|
||||
<v-col cols="12" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-4">{{ getEmptyText() }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-else>
|
||||
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<item-card :item="provider" title-field="id" enabled-field="enable"
|
||||
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
|
||||
<template #item-details="{ item }">
|
||||
<!-- 测试状态 chip -->
|
||||
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
|
||||
<v-icon start size="small">
|
||||
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
|
||||
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
|
||||
'mdi-clock-outline' }}
|
||||
</v-icon>
|
||||
{{ getStatusText(getProviderStatus(item.id).status) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<span v-if="getProviderStatus(item.id).status === 'unavailable'">
|
||||
{{ getProviderStatus(item.id).error }}
|
||||
</span>
|
||||
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
|
||||
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
|
||||
{{ tm('availability.test') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</item-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 日志部分 -->
|
||||
<v-card elevation="0" class="mt-4 mb-10">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon class="me-2">mdi-console-line</v-icon>
|
||||
<span class="text-h4">{{ tm('logs.title') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
|
||||
{{ showConsole ? tm('logs.collapse') : tm('logs.expand') }}
|
||||
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-0" v-if="showConsole">
|
||||
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<!-- 添加提供商对话框 -->
|
||||
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="metadata"
|
||||
@select-template="selectProviderTemplate" />
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showProviderCfg" width="900" persistent>
|
||||
<v-card
|
||||
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
|
||||
<v-card-text class="py-4">
|
||||
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="metadata['provider_group']?.metadata"
|
||||
metadataKey="provider" :is-editing="updatingMode" />
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
|
||||
{{ tm('dialogs.config.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="newProvider" :loading="loading">
|
||||
{{ tm('dialogs.config.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
|
||||
<!-- Agent Runner 测试提示对话框 -->
|
||||
<v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-information</v-icon>
|
||||
请前往「配置文件」页测试 Agent 执行器
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
Agent 执行器的测试请在「配置文件」页进行。
|
||||
<ol class="ml-4 mt-4 mb-4">
|
||||
<li>找到对应的配置文件并打开。</li>
|
||||
<li>找到 Agent 执行方式部分,修改执行器后点击保存。</li>
|
||||
<li>点击右下角的 💬 聊天按钮进行测试。</li>
|
||||
</ol>
|
||||
要让机器人应用这个 Agent 执行器,你也需要前往修改 Agent 执行器。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="showAgentRunnerDialog = false">好的</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="goToConfigPage">点击前往</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- ID冲突确认对话框 -->
|
||||
<v-dialog v-model="showIdConflictDialog" max-width="450" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-warning d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
|
||||
ID 冲突警告
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
检测到 ID "{{ conflictId }}" 重复。请使用一个新的 ID。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">好的</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Key为空的确认对话框 -->
|
||||
<v-dialog v-model="showKeyConfirm" max-width="450" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-error d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
|
||||
确认保存
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
您没有填写 API Key,确定要保存吗?这可能会导致该模型无法正常工作。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleKeyConfirm(false)">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="handleKeyConfirm(true)">确定</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import AddNewProvider from '@/components/provider/AddNewProvider.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { getProviderIcon } from '@/utils/providerUtils';
|
||||
|
||||
export default {
|
||||
name: 'ProviderPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCard,
|
||||
AddNewProvider
|
||||
},
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/provider');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config_data: {},
|
||||
fetched: false,
|
||||
metadata: {},
|
||||
showProviderCfg: false,
|
||||
|
||||
// ID冲突确认对话框
|
||||
showIdConflictDialog: false,
|
||||
conflictId: '',
|
||||
idConflictResolve: null,
|
||||
|
||||
// Key确认对话框
|
||||
showKeyConfirm: false,
|
||||
keyConfirmResolve: null,
|
||||
|
||||
// Agent Runner 提示对话框
|
||||
showAgentRunnerDialog: false,
|
||||
|
||||
newSelectedProviderName: '',
|
||||
newSelectedProviderConfig: {},
|
||||
updatingMode: false,
|
||||
|
||||
loading: false,
|
||||
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
|
||||
// 显示状态部分
|
||||
showStatus: false,
|
||||
|
||||
// 供应商状态相关
|
||||
providerStatuses: [],
|
||||
testingProviders: [], // 存储正在测试的 provider ID
|
||||
|
||||
// 新增提供商对话框相关
|
||||
showAddProviderDialog: false,
|
||||
|
||||
// 添加提供商类型分类
|
||||
activeProviderTypeTab: 'all',
|
||||
|
||||
// 兼容旧版本(< v3.5.11)的 mapping,用于映射到对应的提供商能力类型
|
||||
oldVersionProviderTypeMapping: {
|
||||
"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",
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
showIdConflictDialog(newValue) {
|
||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||
if (!newValue && this.idConflictResolve) {
|
||||
this.idConflictResolve(false);
|
||||
this.idConflictResolve = null;
|
||||
}
|
||||
},
|
||||
showKeyConfirm(newValue) {
|
||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||
if (!newValue && this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(false);
|
||||
this.keyConfirmResolve = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 翻译消息的计算属性
|
||||
messages() {
|
||||
return {
|
||||
emptyText: {
|
||||
all: this.tm('providers.empty.all'),
|
||||
typed: this.tm('providers.empty.typed')
|
||||
},
|
||||
tabTypes: {
|
||||
'chat_completion': this.tm('providers.tabs.chatCompletion'),
|
||||
'agent_runner': this.tm('providers.tabs.agentRunner'),
|
||||
'speech_to_text': this.tm('providers.tabs.speechToText'),
|
||||
'text_to_speech': this.tm('providers.tabs.textToSpeech'),
|
||||
'embedding': this.tm('providers.tabs.embedding'),
|
||||
'rerank': this.tm('providers.tabs.rerank')
|
||||
},
|
||||
success: {
|
||||
update: this.tm('messages.success.update'),
|
||||
add: this.tm('messages.success.add'),
|
||||
delete: this.tm('messages.success.delete'),
|
||||
statusUpdate: this.tm('messages.success.statusUpdate'),
|
||||
},
|
||||
error: {
|
||||
fetchStatus: this.tm('messages.error.fetchStatus'),
|
||||
testError: this.tm('messages.error.testError')
|
||||
},
|
||||
confirm: {
|
||||
delete: this.tm('messages.confirm.delete')
|
||||
},
|
||||
status: {
|
||||
available: this.tm('availability.available'),
|
||||
unavailable: this.tm('availability.unavailable'),
|
||||
pending: this.tm('availability.pending')
|
||||
},
|
||||
availability: {
|
||||
test: this.tm('availability.test')
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
groupedProviders() {
|
||||
if (!this.config_data.provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const typeOrder = [
|
||||
'chat_completion',
|
||||
'agent_runner',
|
||||
'speech_to_text',
|
||||
'text_to_speech',
|
||||
'embedding',
|
||||
'rerank',
|
||||
];
|
||||
|
||||
const assigned = new Set();
|
||||
const groups = typeOrder
|
||||
.map((typeKey) => {
|
||||
const items = this.config_data.provider.filter((provider) => {
|
||||
const resolved = this.getProviderType(provider);
|
||||
if (resolved === typeKey) {
|
||||
assigned.add(provider.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return {
|
||||
typeKey,
|
||||
label: this.messages.tabTypes[typeKey] || typeKey,
|
||||
items,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
const remaining = this.config_data.provider.filter(
|
||||
(provider) => !assigned.has(provider.id),
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
groups.push({
|
||||
typeKey: 'others',
|
||||
label: this.tm('providers.tabs.all'),
|
||||
items: remaining,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
|
||||
// 根据选择的标签过滤提供商列表
|
||||
filteredProviders() {
|
||||
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
||||
return this.config_data.provider || [];
|
||||
}
|
||||
|
||||
return this.config_data.provider.filter(provider => {
|
||||
// 如果provider.provider_type已经存在,直接使用它
|
||||
return this.getProviderType(provider) === this.activeProviderTypeTab;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getProviderType(provider) {
|
||||
if (!provider) return undefined;
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type;
|
||||
}
|
||||
return this.oldVersionProviderTypeMapping[provider.type];
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
this.fetched = true
|
||||
this.metadata = res.data.data.metadata;
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
// 从工具函数导入
|
||||
getProviderIcon,
|
||||
|
||||
// 获取空列表文本
|
||||
getEmptyText() {
|
||||
if (this.activeProviderTypeTab === 'all') {
|
||||
return this.messages.emptyText.all;
|
||||
} else {
|
||||
return this.tm('providers.empty.typed', { type: this.getTabTypeName(this.activeProviderTypeTab) });
|
||||
}
|
||||
},
|
||||
|
||||
// 获取Tab类型的中文名称
|
||||
getTabTypeName(tabType) {
|
||||
return this.messages.tabTypes[tabType] || tabType;
|
||||
},
|
||||
|
||||
// 选择提供商模板
|
||||
selectProviderTemplate(name) {
|
||||
this.newSelectedProviderName = name;
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = false;
|
||||
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['provider_group']?.metadata?.provider?.config_template[name] || {}
|
||||
));
|
||||
},
|
||||
|
||||
configExistingProvider(provider) {
|
||||
this.newSelectedProviderName = provider.id;
|
||||
this.newSelectedProviderConfig = {};
|
||||
|
||||
// 比对默认配置模版,看看是否有更新
|
||||
let templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
let defaultConfig = {};
|
||||
for (let key in templates) {
|
||||
if (templates[key]?.type === provider.type) {
|
||||
defaultConfig = templates[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const mergeConfigWithOrder = (target, source, reference) => {
|
||||
// 首先复制所有source中的属性到target
|
||||
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
||||
for (let key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
if (typeof source[key] === 'object' && source[key] !== null) {
|
||||
target[key] = Array.isArray(source[key]) ? [...source[key]] : { ...source[key] };
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 然后根据reference的结构添加或覆盖属性
|
||||
for (let key in reference) {
|
||||
if (typeof reference[key] === 'object' && reference[key] !== null) {
|
||||
if (!(key in target)) {
|
||||
// 如果target中没有这个key
|
||||
if (Array.isArray(reference[key])) {
|
||||
// 复制
|
||||
target[key] = [...reference[key]]
|
||||
} else {
|
||||
target[key] = {};
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(reference[key])) {
|
||||
mergeConfigWithOrder(
|
||||
target[key],
|
||||
source && source[key] ? source[key] : {},
|
||||
reference[key]
|
||||
);
|
||||
}
|
||||
} else if (!(key in target)) {
|
||||
target[key] = reference[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (defaultConfig) {
|
||||
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
|
||||
}
|
||||
|
||||
this.showProviderCfg = true;
|
||||
this.updatingMode = true;
|
||||
},
|
||||
|
||||
async newProvider() {
|
||||
// 检查 key 是否为空
|
||||
if (
|
||||
'key' in this.newSelectedProviderConfig &&
|
||||
(!this.newSelectedProviderConfig.key || this.newSelectedProviderConfig.key.length === 0)
|
||||
) {
|
||||
const confirmed = await this.confirmEmptyKey();
|
||||
if (!confirmed) {
|
||||
return; // 如果用户取消,则中止保存
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
const wasUpdating = this.updatingMode;
|
||||
try {
|
||||
if (wasUpdating) {
|
||||
const res = await axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedProviderName,
|
||||
config: this.newSelectedProviderConfig
|
||||
});
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message || "更新失败!");
|
||||
return
|
||||
}
|
||||
this.showSuccess(res.data.message || "更新成功!");
|
||||
if (wasUpdating) {
|
||||
this.updatingMode = false;
|
||||
}
|
||||
} else {
|
||||
// 检查 ID 是否已存在
|
||||
const existingProvider = this.config_data.provider?.find(p => p.id === this.newSelectedProviderConfig.id);
|
||||
if (existingProvider) {
|
||||
const confirmed = await this.confirmIdConflict(this.newSelectedProviderConfig.id);
|
||||
if (!confirmed) {
|
||||
this.loading = false;
|
||||
return; // 如果用户取消,则中止保存
|
||||
}
|
||||
}
|
||||
|
||||
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message || "添加失败!");
|
||||
return
|
||||
}
|
||||
this.showSuccess(res.data.message || "添加成功!");
|
||||
}
|
||||
this.showProviderCfg = false;
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.getConfig();
|
||||
}
|
||||
},
|
||||
|
||||
async copyProvider(providerToCopy) {
|
||||
console.log('copyProvider triggered for:', providerToCopy);
|
||||
// 1. 创建深拷贝
|
||||
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy));
|
||||
|
||||
// 2. 生成唯一的 ID
|
||||
const generateUniqueId = (baseId) => {
|
||||
let newId = `${baseId}_copy`;
|
||||
let counter = 1;
|
||||
const existingIds = this.config_data.provider.map(p => p.id);
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}_copy_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
return newId;
|
||||
};
|
||||
newProviderConfig.id = generateUniqueId(providerToCopy.id);
|
||||
|
||||
// 3. 设置为禁用状态,等待用户手动开启
|
||||
newProviderConfig.enable = false;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 4. 调用后端接口创建
|
||||
const res = await axios.post('/api/config/provider/new', newProviderConfig);
|
||||
this.showSuccess(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`);
|
||||
this.getConfig(); // 5. 刷新列表
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider(provider) {
|
||||
if (confirm(this.tm('messages.confirm.delete', { id: provider.id }))) {
|
||||
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.success.delete);
|
||||
}).catch((err) => {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
providerStatusChange(provider) {
|
||||
provider.enable = !provider.enable; // 切换状态
|
||||
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: provider.id,
|
||||
config: provider
|
||||
}).then((res) => {
|
||||
if (res.data.status === 'error') {
|
||||
this.showError(res.data.message)
|
||||
return
|
||||
}
|
||||
this.getConfig();
|
||||
this.showSuccess(res.data.message || this.messages.success.statusUpdate);
|
||||
}).catch((err) => {
|
||||
provider.enable = !provider.enable; // 发生错误时回滚状态
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
// 获取供应商状态
|
||||
async fetchProviderStatus() {
|
||||
if (this.testingProviders.length > 0) return;
|
||||
|
||||
this.showStatus = true; // 自动展开状态部分
|
||||
|
||||
const providersToTest = this.config_data.provider.filter(p => p.enable);
|
||||
if (providersToTest.length === 0) return;
|
||||
|
||||
// 1. 初始化UI为pending状态,并将所有待测试的 provider ID 加入 loading 列表
|
||||
this.providerStatuses = providersToTest.map(p => {
|
||||
this.testingProviders.push(p.id);
|
||||
return { id: p.id, name: p.id, status: 'pending', error: null };
|
||||
});
|
||||
|
||||
// 2. 为每个provider创建一个并发的测试请求
|
||||
const promises = providersToTest.map(p =>
|
||||
axios.get(`/api/config/provider/check_one?id=${p.id}`)
|
||||
.then(res => {
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||
if (index !== -1) this.providerStatuses.splice(index, 1, res.data.data);
|
||||
} else {
|
||||
throw new Error(res.data?.message || `Failed to check status for ${p.id}`);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Unknown error';
|
||||
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||
if (index !== -1) {
|
||||
const failedStatus = { ...this.providerStatuses[index], status: 'unavailable', error: errorMessage };
|
||||
this.providerStatuses.splice(index, 1, failedStatus);
|
||||
}
|
||||
return Promise.reject(errorMessage); // Propagate error for Promise.allSettled
|
||||
})
|
||||
);
|
||||
|
||||
// 3. 等待所有请求完成
|
||||
try {
|
||||
await Promise.allSettled(promises);
|
||||
} finally {
|
||||
// 4. 关闭所有加载状态
|
||||
this.testingProviders = [];
|
||||
}
|
||||
},
|
||||
|
||||
isProviderTesting(providerId) {
|
||||
return this.testingProviders.includes(providerId);
|
||||
},
|
||||
|
||||
getProviderStatus(providerId) {
|
||||
return this.providerStatuses.find(s => s.id === providerId);
|
||||
},
|
||||
|
||||
async testSingleProvider(provider) {
|
||||
if (this.isProviderTesting(provider.id)) return;
|
||||
|
||||
this.testingProviders.push(provider.id);
|
||||
|
||||
// 更新UI为pending状态
|
||||
const statusIndex = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
const pendingStatus = {
|
||||
id: provider.id,
|
||||
name: provider.id,
|
||||
status: 'pending',
|
||||
error: null
|
||||
};
|
||||
if (statusIndex !== -1) {
|
||||
this.providerStatuses.splice(statusIndex, 1, pendingStatus);
|
||||
} else {
|
||||
this.providerStatuses.unshift(pendingStatus);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!provider.enable) {
|
||||
throw new Error('该提供商未被用户启用');
|
||||
}
|
||||
if (provider.provider_type === 'agent_runner') {
|
||||
this.showAgentRunnerDialog = true;
|
||||
this.providerStatuses = this.providerStatuses.filter(s => s.id !== provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`);
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
const index = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
if (index !== -1) {
|
||||
this.providerStatuses.splice(index, 1, res.data.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Unknown error';
|
||||
const index = this.providerStatuses.findIndex(s => s.id === provider.id);
|
||||
const failedStatus = {
|
||||
id: provider.id,
|
||||
name: provider.id,
|
||||
status: 'unavailable',
|
||||
error: errorMessage
|
||||
};
|
||||
if (index !== -1) {
|
||||
this.providerStatuses.splice(index, 1, failedStatus);
|
||||
}
|
||||
// 不再显示全局的错误提示,因为卡片本身会显示错误信息
|
||||
// this.showError(this.tm('messages.error.testError', { id: provider.id, error: errorMessage }));
|
||||
} finally {
|
||||
const index = this.testingProviders.indexOf(provider.id);
|
||||
if (index > -1) {
|
||||
this.testingProviders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
confirmEmptyKey() {
|
||||
this.showKeyConfirm = true;
|
||||
return new Promise((resolve) => {
|
||||
this.keyConfirmResolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyConfirm(confirmed) {
|
||||
if (this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(confirmed);
|
||||
}
|
||||
this.showKeyConfirm = false;
|
||||
},
|
||||
|
||||
confirmIdConflict(id) {
|
||||
this.conflictId = id;
|
||||
this.showIdConflictDialog = true;
|
||||
return new Promise((resolve) => {
|
||||
this.idConflictResolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
handleIdConflictConfirm(confirmed) {
|
||||
if (this.idConflictResolve) {
|
||||
this.idConflictResolve(confirmed);
|
||||
}
|
||||
this.showIdConflictDialog = false;
|
||||
},
|
||||
goToConfigPage() {
|
||||
this.showAgentRunnerDialog = false;
|
||||
this.$router.push({ name: 'Configs' });
|
||||
},
|
||||
getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'success';
|
||||
case 'unavailable':
|
||||
return 'error';
|
||||
case 'pending':
|
||||
return 'grey';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusText(status) {
|
||||
return this.messages.status[status] || status;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-page {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user