feat(model): 添加自定义模型支持(Fix #304)

This commit is contained in:
Wenjie Zhang 2025-11-16 21:24:26 +08:00
parent 967014a72d
commit 9a8f1c4ae3
10 changed files with 1194 additions and 158 deletions

View File

@ -11,7 +11,6 @@
- 开发与生产环境隔离,构建生产镜像 <Badge type="info" text="0.4" />
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
- 集成 neo4j mcp (或者自己构建工具)
- 工具组件重构以支持 todofiles 等渲染。
- 文档解析部分的 markdown 中的图片替换为内部可访问的链接
### Bugs
@ -24,10 +23,11 @@
- 新增对于上传文件的智能体中间件
- 增强文件下载功能
- 新增多模态模型支持(当前仅支持图片,详见文档)
- 新建 DeepAgents 智能体Demo
- 新建 DeepAgents 智能体Demo,支持 todofiles 等渲染。
- 新增基于知识库文件生成思维导图功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425)
- 新增基于知识库文件生成示例问题功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425)
- 新增知识库支持文件夹/压缩包上传的功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425)
- 新增自定义模型支持
### 修复
- 修复重排序模型实际未生效的问题

View File

@ -4,14 +4,14 @@
系统支持多种大语言模型服务商,通过配置对应的 API 密钥即可使用:
| 服务商 | 环境变量 | 特点 |
|--------|----------|------|
| 服务商 | 环境变量 | 特点 |
| ------------------------------------------------ | ----------------------- | --------------------- |
| [硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) | `SILICONFLOW_API_KEY` | 🆓 免费额度,默认推荐 |
| OpenAI | `OPENAI_API_KEY` | GPT 系列模型 |
| DeepSeek | `DEEPSEEK_API_KEY` | 国产大模型 |
| OpenRouter | `OPENROUTER_API_KEY` | 多模型聚合平台 |
| 智谱清言 | `ZHIPUAI_API_KEY` | GLM 系列模型 |
| 阿里云百炼 | `DASHSCOPE_API_KEY` | 通义千问系列 |
| OpenAI | `OPENAI_API_KEY` | GPT 系列模型 |
| DeepSeek | `DEEPSEEK_API_KEY` | 国产大模型 |
| OpenRouter | `OPENROUTER_API_KEY` | 多模型聚合平台 |
| 智谱清言 | `ZHIPUAI_API_KEY` | GLM 系列模型 |
| 阿里云百炼 | `DASHSCOPE_API_KEY` | 通义千问系列 |
其余还支持火山、Together、vLLM、Ollama 等。
@ -19,7 +19,7 @@
`.env` 文件中添加对应的环境变量:
<<< @/../.env.template#model_provider{bash 2}
<<< @/../.env.template#model_provider{bash 5}
### 默认对话模型格式
@ -29,8 +29,7 @@
default_model: siliconflow/deepseek-ai/DeepSeek-V3.2-Exp
```
在 Web 界面中选择模型时也会自动按照这一格式保存,无需手动拆分提供商和模型名称。
在 Web 界面中选择模型时也会自动按照这一格式保存。
::: tip 免费获取 API Key
[硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) 注册即送 14 元额度,支持多种开源模型。
@ -38,93 +37,123 @@ default_model: siliconflow/deepseek-ai/DeepSeek-V3.2-Exp
## 自定义模型供应商
::: warning
原本网页中的自定义模型已在 `0.3.x` 版本移除,请在 `src/config/static/models.py` 中按如下方式配置,并重启服务后选择并使用。此外,这里也推荐一下团队的另外一个小工具 [mvllm (Manage and Route vLLM Servers)](https://github.com/xerrors/mvllm)。
:::
::: tip 配置系统升级 (v0.3.x)
`v0.3.x` 版本开始,模型配置系统已升级为基于 Pydantic BaseModel 的类型安全配置,支持 TOML 格式的用户配置文件。
- **默认配置**: `src/config/static/models.py` (Python 代码)
- **用户配置**: `saves/config/base.toml` (TOML 格式,仅保存用户修改)
:::
- **自定义供应商**: `saves/config/custom_providers.toml` (独立配置文件)
:::
系统理论上兼容任何 OpenAI 兼容的模型服务,包括:
系统提供了完整的自定义供应商管理功能,支持通过 Web 界面直接添加、编辑、测试和删除自定义模型供应商。
- **vLLM**: 高性能推理服务
- **Ollama**: 本地模型管理
- **API 中转服务**: 各种代理和聚合服务
### 使用方法
如需添加新的模型供应商,请按以下步骤操作:
系统支持任何 OpenAI 兼容的云服务提供商
### 1. 编辑模型配置文件
#### 1. Web 界面操作(推荐)
**方式一:修改默认配置(推荐)**
编辑 `src/config/static/models.py` 文件中的 `DEFAULT_CHAT_MODEL_PROVIDERS` 字典
访问 **系统设置 > 模型配置**,在"自定义供应商"部分点击 **添加自定义供应商**。这里的密钥可以直接填写也可以填写对应的环境变量名称。
`src/config/static/models.py` 中添加新的模型供应商:
#### 2. 配置文件操作
```python
DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
# ... 现有配置 ...
如需通过配置文件管理,编辑 `saves/config/custom_providers.toml`
"custom-provider": ChatModelProvider(
name="自定义提供商",
url="https://your-provider.com/docs",
base_url="https://api.your-provider.com/v1",
default="custom-model-name",
env="CUSTOM_API_KEY_ENV_NAME",
models=[
"supported-model-name",
"another-model-name",
],
),
```toml
[model_names.local-vllm]
name = "本地 vLLM 服务"
url = "https://docs.vllm.ai"
base_url = "http://localhost:8000/v1"
default = "Qwen/Qwen2.5-7B-Instruct"
env = "LOCAL_VLLM_API_KEY"
models = [
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-14B-Instruct",
]
custom = true
# 本地 Ollama 服务
"local-ollama": ChatModelProvider(
name="Local Ollama",
url="https://ollama.com",
base_url="http://localhost:11434/v1",
default="llama3.2",
env="NO_API_KEY", # 对于不需要API Key的服务使用NO_API_KEY
models=["llama3.2", "qwen2.5"],
),
# 本地 vLLM 服务
"local-vllm": ChatModelProvider(
name="Local vLLM",
url="https://docs.vllm.ai",
base_url="http://localhost:8000/v1",
default="Qwen/Qwen2.5-7B-Instruct",
env="NO_API_KEY",
models=[
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-14B-Instruct",
],
),
}
[model_names.local-ollama]
name = "本地 Ollama"
url = "https://ollama.com"
base_url = "http://localhost:11434/v1"
default = "llama3.2"
env = "NO_API_KEY"
models = ["llama3.2", "qwen2.5"]
custom = true
```
### 2. 配置环境变量
然后在 `.env` 文件中添加对应的环境变量:
`.env` 文件中添加对应的环境变量:
```env
CUSTOM_API_KEY_ENV_NAME=your_api_key_here
LOCAL_VLLM_API_KEY=your_api_key_here
```
### 3. 重新部署
### API 端点
```bash
docker compose restart api-dev
系统提供以下 API 端点管理自定义供应商:
- `GET /api/system/custom-providers` - 获取所有自定义供应商
- `POST /api/system/custom-providers` - 添加自定义供应商
- `PUT /api/system/custom-providers/{provider_id}` - 更新自定义供应商
- `DELETE /api/system/custom-providers/{provider_id}` - 删除自定义供应商
- `POST /api/system/custom-providers/{provider_id}/test` - 测试供应商连接
### 常见配置示例
#### vLLM 本地服务
```toml
[model_names.vllm-local]
name = "vLLM 本地服务"
base_url = "http://localhost:8000/v1"
default = "Qwen/Qwen2.5-7B-Instruct"
env = "NO_API_KEY"
models = [
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-14B-Instruct",
"meta-llama/Llama-3.1-8B-Instruct"
]
```
#### Ollama 本地服务
```toml
[model_names.ollama-local]
name = "Ollama 本地服务"
base_url = "http://localhost:11434/v1"
default = "llama3.2"
env = "NO_API_KEY"
models = [
"llama3.2:latest",
"qwen2.5:latest",
"codellama:latest"
]
```
#### 第三方 API 中转服务
```toml
[model_names.api-proxy]
name = "API 中转服务"
base_url = "https://api-proxy.example.com/v1"
default = "gpt-3.5-turbo"
env = "API_PROXY_KEY"
models = [
"gpt-3.5-turbo",
"gpt-4",
"claude-3-sonnet"
]
```
### 故障排除
1. **测试连接失败**: 检查 API 地址格式和 API 密钥配置
2. **模型不可用**: 确认模型名称拼写和服务端是否支持该模型
3. **权限错误**: 确保用户具有管理员权限
4. **配置未生效**: 检查环境变量配置和服务重启状态
## 嵌入模型和重排序模型
::: warning 重要说明
从 v0.2 版本开始,项目采用微服务架构,模型部署与项目本身完全解耦。如需使用本地模型,需要先通过 vLLM 或 Ollama 部署为 API 服务。
:::
### 本地模型部署
#### 1. 配置模型信息
`src/config/static/models.py` 中的默认配置部分添加:
@ -195,4 +224,4 @@ vllm serve BAAI/bge-reranker-v2-m3 \
--task score \
--dtype fp16 \
--port 8000
```
```

View File

@ -210,3 +210,102 @@ async def get_all_chat_models_status(current_user: User = Depends(get_admin_user
except Exception as e:
logger.error(f"获取所有聊天模型状态失败: {e}")
return {"message": f"获取所有聊天模型状态失败: {e}", "status": {"models": {}, "total": 0, "available": 0}}
# =============================================================================
# === 自定义供应商管理分组 ===
# =============================================================================
@system.get("/custom-providers")
async def get_custom_providers(current_user: User = Depends(get_admin_user)):
"""获取所有自定义供应商"""
try:
custom_providers = config.get_custom_providers()
return {
"providers": {provider: info.model_dump() for provider, info in custom_providers.items()},
"message": "success"
}
except Exception as e:
logger.error(f"获取自定义供应商失败: {e}")
raise HTTPException(status_code=500, detail=f"获取自定义供应商失败: {str(e)}")
@system.post("/custom-providers")
async def add_custom_provider(
provider_id: str = Body(..., description="供应商ID"),
provider_data: dict = Body(..., description="供应商配置数据"),
current_user: User = Depends(get_admin_user)
):
"""添加自定义供应商"""
try:
success = config.add_custom_provider(provider_id, provider_data)
if success:
return {"message": f"自定义供应商 {provider_id} 添加成功"}
else:
raise HTTPException(status_code=400, detail=f"供应商ID {provider_id} 已存在请使用其他ID")
except Exception as e:
logger.error(f"添加自定义供应商失败 {provider_id}: {e}")
raise HTTPException(status_code=500, detail=f"添加自定义供应商失败: {str(e)}")
@system.put("/custom-providers/{provider_id}")
async def update_custom_provider(
provider_id: str,
provider_data: dict = Body(..., description="供应商配置数据"),
current_user: User = Depends(get_admin_user)
):
"""更新自定义供应商"""
try:
success = config.update_custom_provider(provider_id, provider_data)
if success:
return {"message": f"自定义供应商 {provider_id} 更新成功"}
else:
raise HTTPException(status_code=404, detail=f"自定义供应商 {provider_id} 不存在或更新失败")
except Exception as e:
logger.error(f"更新自定义供应商失败 {provider_id}: {e}")
raise HTTPException(status_code=500, detail=f"更新自定义供应商失败: {str(e)}")
@system.delete("/custom-providers/{provider_id}")
async def delete_custom_provider(provider_id: str, current_user: User = Depends(get_admin_user)):
"""删除自定义供应商"""
try:
success = config.delete_custom_provider(provider_id)
if success:
return {"message": f"自定义供应商 {provider_id} 删除成功"}
else:
raise HTTPException(status_code=404, detail=f"自定义供应商 {provider_id} 不存在或删除失败")
except Exception as e:
logger.error(f"删除自定义供应商失败 {provider_id}: {e}")
raise HTTPException(status_code=500, detail=f"删除自定义供应商失败: {str(e)}")
@system.post("/custom-providers/{provider_id}/test")
async def test_custom_provider(
provider_id: str,
request: dict = Body(..., description="测试请求"),
current_user: User = Depends(get_admin_user)
):
"""测试自定义供应商连接"""
try:
# 从请求中获取model_name
model_name = request.get("model_name")
if not model_name:
raise HTTPException(status_code=400, detail="缺少model_name参数")
# 检查供应商是否存在
if provider_id not in config.model_names:
raise HTTPException(status_code=404, detail=f"供应商 {provider_id} 不存在")
# 测试模型状态
status = await test_chat_model_status(provider_id, model_name)
return {"status": status, "message": "测试完成"}
except HTTPException:
raise
except Exception as e:
logger.error(f"测试自定义供应商失败 {provider_id}/{model_name}: {e}")
return {
"message": f"测试自定义供应商失败: {e}",
"status": {"provider": provider_id, "model_name": model_name, "status": "error", "message": str(e)},
}

View File

@ -26,6 +26,8 @@ from src.config.static.models import (
from src.utils.logging_config import logger
class Config(BaseModel):
"""应用配置类"""
@ -116,6 +118,7 @@ class Config(BaseModel):
super().__init__(**data)
self._setup_paths()
self._load_user_config()
self._load_custom_providers()
self._handle_environment()
def _setup_paths(self):
@ -166,6 +169,36 @@ class Config(BaseModel):
except Exception as e:
logger.error(f"Failed to load model names: {e}")
def _load_custom_providers(self):
"""从独立的TOML文件加载自定义供应商配置"""
custom_config_file = self._config_file.parent / "custom_providers.toml"
if not custom_config_file.exists():
logger.info(f"Custom providers config file not found: {custom_config_file}")
return
logger.info(f"Loading custom providers from {custom_config_file}")
try:
with open(custom_config_file, "rb") as f:
custom_config = tomli.load(f)
# 加载自定义供应商
if "model_names" in custom_config:
self._load_custom_model_providers(custom_config["model_names"])
except Exception as e:
logger.error(f"Failed to load custom providers from {custom_config_file}: {e}")
def _load_custom_model_providers(self, providers_data):
"""加载自定义模型供应商"""
try:
for provider, provider_data in providers_data.items():
provider_data["custom"] = True
self.model_names[provider] = ChatModelProvider(**provider_data)
logger.info(f"Loaded {len(providers_data)} custom model providers")
except Exception as e:
logger.error(f"Failed to load custom model providers: {e}")
def _handle_environment(self):
"""处理环境变量和运行时状态"""
# 处理模型目录
@ -182,10 +215,13 @@ class Config(BaseModel):
self.model_provider_status = {}
for provider, info in self.model_names.items():
env_var = info.env
if env_var == "NO_API_KEY":
self.model_provider_status[provider] = True
else:
self.model_provider_status[provider] = bool(os.getenv(env_var))
api_key = os.environ.get(env_var)
# 如果获取到的值与环境变量名不同,说明环境变量存在或配置了直接值
self.model_provider_status[provider] = bool(api_key or info.custom)
# 检查网络搜索
if os.getenv("TAVILY_API_KEY"):
@ -354,6 +390,170 @@ class Config(BaseModel):
except Exception as e:
logger.error(f"Failed to save models config to {self._config_file}: {e}")
# ============================================================
# 自定义供应商管理方法
# ============================================================
def add_custom_provider(self, provider_id: str, provider_data: dict) -> bool:
"""添加自定义供应商
Args:
provider_id: 供应商唯一标识符
provider_data: 供应商配置数据
Returns:
是否添加成功
"""
try:
# 处理环境变量,移除 ${} 包裹
if "env" in provider_data and provider_data["env"]:
env_value = provider_data["env"]
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
provider_data["env"] = env_value[2:-1]
# 确保标记为自定义供应商
provider_data["custom"] = True
# 检查供应商ID是否已存在无论是内置还是自定义
if provider_id in self.model_names:
logger.error(f"Provider ID already exists: {provider_id}")
return False
# 添加到配置中
self.model_names[provider_id] = ChatModelProvider(**provider_data)
# 保存到自定义供应商配置文件
self._save_custom_providers()
# 重新处理环境变量
self._handle_environment()
logger.info(f"Added custom provider: {provider_id}")
return True
except Exception as e:
logger.error(f"Failed to add custom provider {provider_id}: {e}")
return False
def update_custom_provider(self, provider_id: str, provider_data: dict) -> bool:
"""更新自定义供应商
Args:
provider_id: 供应商唯一标识符
provider_data: 新的供应商配置数据
Returns:
是否更新成功
"""
try:
# 处理环境变量,移除 ${} 包裹
if "env" in provider_data and provider_data["env"]:
env_value = provider_data["env"]
if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"):
provider_data["env"] = env_value[2:-1]
# 检查供应商是否存在且为自定义供应商
if provider_id not in self.model_names:
logger.error(f"Provider not found: {provider_id}")
return False
if not self.model_names[provider_id].custom:
logger.error(f"Cannot update non-custom provider: {provider_id}")
return False
# 确保保持自定义供应商标记
provider_data["custom"] = True
# 更新供应商配置
self.model_names[provider_id] = ChatModelProvider(**provider_data)
# 保存到自定义供应商配置文件
self._save_custom_providers()
# 重新处理环境变量
self._handle_environment()
logger.info(f"Updated custom provider: {provider_id}")
return True
except Exception as e:
logger.error(f"Failed to update custom provider {provider_id}: {e}")
return False
def delete_custom_provider(self, provider_id: str) -> bool:
"""删除自定义供应商
Args:
provider_id: 供应商唯一标识符
Returns:
是否删除成功
"""
try:
# 检查供应商是否存在且为自定义供应商
if provider_id not in self.model_names:
logger.error(f"Provider not found: {provider_id}")
return False
if not self.model_names[provider_id].custom:
logger.error(f"Cannot delete non-custom provider: {provider_id}")
return False
# 从配置中删除
del self.model_names[provider_id]
# 保存到自定义供应商配置文件
self._save_custom_providers()
# 重新处理环境变量
self._handle_environment()
logger.info(f"Deleted custom provider: {provider_id}")
return True
except Exception as e:
logger.error(f"Failed to delete custom provider {provider_id}: {e}")
return False
def get_custom_providers(self) -> dict[str, ChatModelProvider]:
"""获取所有自定义供应商
Returns:
自定义供应商字典
"""
return {k: v for k, v in self.model_names.items() if v.custom}
def _save_custom_providers(self):
"""保存自定义供应商到独立配置文件"""
if not self._config_file:
logger.warning("Config file path not set")
return
custom_config_file = self._config_file.parent / "custom_providers.toml"
try:
# 获取所有自定义供应商
custom_providers = self.get_custom_providers()
# 创建配置数据
custom_config = {}
if custom_providers:
custom_config["model_names"] = {
provider: info.model_dump() for provider, info in custom_providers.items()
}
# 确保目录存在
custom_config_file.parent.mkdir(parents=True, exist_ok=True)
# 写入配置文件
with open(custom_config_file, "wb") as f:
tomli_w.dump(custom_config, f)
logger.info(f"Custom providers saved to {custom_config_file}")
except Exception as e:
logger.error(f"Failed to save custom providers to {custom_config_file}: {e}")
# 全局配置实例
config = Config()

View File

@ -19,6 +19,7 @@ class ChatModelProvider(BaseModel):
default: str = Field(..., description="默认模型名称")
env: str = Field(..., description="API Key 环境变量名")
models: list[str] = Field(default_factory=list, description="支持的模型列表")
custom: bool = Field(default=False, description="是否为自定义供应商")
class EmbedModelInfo(BaseModel):

View File

@ -131,7 +131,7 @@ def select_model(model_provider=None, model_name=None, model_spec=None):
# 其他模型默认使用OpenAIBase
try:
model = OpenAIBase(
api_key=os.getenv(model_info.env),
api_key=os.environ.get(model_info.env, model_info.env),
base_url=model_info.base_url,
model_name=model_name,
)

View File

@ -56,6 +56,17 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
errorData = await response.json()
errorMessage = errorData.detail || errorData.message || errorMessage
console.log('API错误详情:', errorData);
// 如果是422错误打印更详细的信息
if (response.status === 422) {
console.error('422验证错误详情:', {
url,
requestMethod: requestOptions.method,
requestHeaders: requestOptions.headers,
requestBody: requestOptions.body,
responseData: errorData
});
}
} catch (e) {
// 如果无法解析JSON使用默认错误信息
console.log('无法解析错误响应JSON:', e);

View File

@ -1,4 +1,4 @@
import { apiGet, apiPost, apiAdminGet, apiAdminPost, apiSuperAdminPost } from './base'
import { apiGet, apiPost, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
/**
* 系统管理API模块
@ -51,7 +51,7 @@ export const configApi = {
*/
updateConfigBatch: async (items) => apiAdminPost('/api/system/config/update', items),
/**
* 获取系统日志
* @returns {Promise} - 系统日志
@ -119,5 +119,58 @@ export const chatModelApi = {
}
}
// =============================================================================
// === 自定义供应商管理分组 ===
// =============================================================================
export const customProviderApi = {
/**
* 获取所有自定义供应商
* @returns {Promise} - 自定义供应商列表
*/
getCustomProviders: async () => {
return apiAdminGet('/api/system/custom-providers')
},
/**
* 添加自定义供应商
* @param {string} providerId - 供应商ID
* @param {Object} providerData - 供应商配置数据
* @returns {Promise} - 添加结果
*/
addCustomProvider: async (providerId, providerData) => {
return apiAdminPost('/api/system/custom-providers', { provider_id: providerId, provider_data: providerData })
},
/**
* 更新自定义供应商
* @param {string} providerId - 供应商ID
* @param {Object} providerData - 供应商配置数据
* @returns {Promise} - 更新结果
*/
updateCustomProvider: async (providerId, providerData) => {
return apiAdminPut(`/api/system/custom-providers/${encodeURIComponent(providerId)}`, providerData)
},
/**
* 删除自定义供应商
* @param {string} providerId - 供应商ID
* @returns {Promise} - 删除结果
*/
deleteCustomProvider: async (providerId) => {
return apiAdminDelete(`/api/system/custom-providers/${encodeURIComponent(providerId)}`)
},
/**
* 测试自定义供应商连接
* @param {string} providerId - 供应商ID
* @param {string} modelName - 要测试的模型名称
* @returns {Promise} - 测试结果
*/
testCustomProvider: async (providerId, modelName) => {
return apiAdminPost(`/api/system/custom-providers/${encodeURIComponent(providerId)}/test`, { model_name: modelName })
}
}

View File

@ -1,62 +1,156 @@
<template>
<div>
<a-alert message="自定义模型在 0.3.x 的稳定版中移除,只能通过修改 src/config/static/models.py 来添加模型和供应商。" type="warning" />
<br>
<div class="model-provider-card configured-provider" v-for="(item, key) in modelKeys" :key="key">
<div class="card-header" @click="toggleExpand(item)">
<div :class="{'model-icon': true, 'available': modelStatus[item]}">
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标">
</div>
<div class="model-title-container">
<h3>{{ modelNames[item].name }}</h3>
</div>
<a-button
type="text"
class="expand-button"
@click.stop="openProviderConfig(item)"
title="配置模型提供商"
>
<SettingOutlined />
</a-button>
<a-button
type="text"
class="expand-button"
@click.stop="toggleExpand(item)"
>
<span class="icon-wrapper" :class="{'rotated': expandedModels[item]}">
<DownCircleOutlined />
</span>
<!-- 自定义供应商管理区域 -->
<div class="custom-providers-section">
<div class="section-header">
<h3>自定义供应商</h3>
<a-button type="primary" @click="openAddCustomProviderModal">
<template #icon>
<PlusOutlined />
</template>
添加自定义供应商
</a-button>
</div>
<div class="card-body-wrapper" :class="{'expanded': expandedModels[item]}">
<div class="card-body" v-if="modelStatus[item]">
<div class="card-models" v-for="(model, idx) in modelNames[item].models" :key="idx">
<div class="model_name">{{ model }}</div>
<p class="section-description">
添加自定义的LLM供应商支持OpenAI兼容的API格式API密钥支持直接配置或使用环境变量名
</p>
<!-- 自定义供应商列表 -->
<div class="custom-provider-card" v-for="(provider, providerId) in customProviders" :key="providerId">
<div class="card-header">
<div class="provider-info">
<h4>{{ provider.name }}</h4>
<span class="provider-id">{{ providerId }}</span>
</div>
<div class="provider-actions">
<a-button type="text" size="small" @click="testCustomProvider(providerId, provider.default)">
<template #icon>
<ApiOutlined />
</template>
测试连接
</a-button>
<a-button type="text" size="small" @click="openEditCustomProviderModal(providerId, provider)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-popconfirm
title="确定要删除这个自定义供应商吗?"
@confirm="deleteCustomProvider(providerId)"
ok-text="确定"
cancel-text="取消"
>
<a-button type="text" size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
</div>
</div>
<div class="card-content">
<div class="provider-details">
<div class="detail-item">
<span class="label">API地址:</span>
<span class="value">{{ provider.base_url }}</span>
</div>
<div class="detail-item">
<span class="label">默认模型:</span>
<span class="value">{{ provider.default }}</span>
</div>
<div class="detail-item">
<span class="label">可用模型:</span>
<span class="value">{{ provider.models?.join(', ') || '无' }}</span>
</div>
</div>
</div>
</div>
<!-- 无自定义供应商时的提示 -->
<div v-if="Object.keys(customProviders).length === 0" class="empty-state">
<a-empty description="暂无自定义供应商">
<a-button type="primary" @click="openAddCustomProviderModal">添加自定义供应商</a-button>
</a-empty>
</div>
</div>
<div class="model-provider-card unconfigured-provider" v-for="(item, key) in notModelKeys" :key="key">
<div class="card-header">
<div class="model-icon">
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标">
<a-divider />
<!-- 系统内置供应商 -->
<div class="builtin-providers-section">
<div class="section-header">
<h3>系统内置供应商</h3>
<div class="providers-stats">
<span class="stats-item available">
{{ modelKeys.length }} 可用
</span>
<span class="stats-item unavailable">
{{ notModelKeys.length }} 未配置
</span>
</div>
<div class="model-title-container">
<h3 style="font-weight: 400">{{ modelNames[item].name }}</h3>
<a :href="modelNames[item].url" target="_blank" class="model-url">
<InfoCircleOutlined />
</a>
</div>
<!-- 已配置的供应商 -->
<div class="model-provider-card configured-provider" v-for="(item, key) in modelKeys" :key="key">
<div class="card-header" @click="toggleExpand(item)">
<div :class="{'model-icon': true, 'available': modelStatus[item]}">
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标">
</div>
<div class="model-title-container">
<h3>{{ modelNames[item].name }}</h3>
<div class="provider-meta">
<span class="model-count">{{ modelNames[item].models?.length || 0 }} 个模型</span>
<span class="provider-id">{{ item }}</span>
</div>
</div>
<div class="provider-actions">
<a-button
type="text"
size="small"
class="expand-button"
@click.stop="openProviderConfig(item)"
title="配置模型"
>
<SettingOutlined />
</a-button>
<a-button
type="text"
size="small"
class="expand-button"
@click.stop="toggleExpand(item)"
>
<span class="icon-wrapper" :class="{'rotated': expandedModels[item]}">
<DownCircleOutlined />
</span>
</a-button>
</div>
</div>
<a-button
type="text"
class="config-button"
@click.stop="openProviderConfig(item)"
title="配置模型提供商"
>
<SettingOutlined />
</a-button>
<div class="missing-keys">
需配置<span>{{ modelNames[item].env }}</span>
<div class="card-body-wrapper" :class="{'expanded': expandedModels[item]}">
<div class="card-body" v-if="modelStatus[item]">
<div class="card-models" v-for="(model, idx) in modelNames[item].models" :key="idx">
<div class="model_name">{{ model }}</div>
</div>
</div>
</div>
</div>
<!-- 未配置的供应商 -->
<div class="model-provider-card unconfigured-provider" v-for="(item, key) in notModelKeys" :key="key">
<div class="card-header">
<div class="model-icon">
<img :src="modelIcons[item] || modelIcons.default" alt="模型图标">
</div>
<div class="model-title-container">
<h3>{{ modelNames[item].name }}</h3>
<a :href="modelNames[item].url" target="_blank" class="model-url">
查看信息 <InfoCircleOutlined />
</a>
</div>
<div class="missing-keys">
需配置<span>{{ modelNames[item].env }}</span>
</div>
</div>
</div>
</div>
@ -128,22 +222,104 @@
</div>
</div>
</a-modal>
<!-- 自定义供应商配置弹窗 -->
<a-modal
v-model:open="customProviderModal.visible"
:title="customProviderModal.isEdit ? '编辑自定义供应商' : '添加自定义供应商'"
@ok="saveCustomProvider"
@cancel="cancelCustomProvider"
:okText="'保存'"
:cancelText="'取消'"
:ok-type="'primary'"
:width="600"
:confirmLoading="customProviderModal.loading"
>
<a-form
ref="customProviderForm"
:model="customProviderModal.data"
:rules="customProviderRules"
layout="vertical"
>
<a-form-item label="供应商ID" name="providerId" v-if="!customProviderModal.isEdit">
<a-input
v-model:value="customProviderModal.data.providerId"
placeholder="请输入唯一的供应商标识符my-provider"
:disabled="customProviderModal.isEdit"
/>
</a-form-item>
<a-form-item label="供应商名称" name="name">
<a-input
v-model:value="customProviderModal.data.name"
placeholder="请输入供应商显示名称"
/>
</a-form-item>
<a-form-item label="API地址" name="base_url">
<a-input
v-model:value="customProviderModal.data.base_url"
placeholder="请输入API基础地址https://api.example.com/v1"
/>
</a-form-item>
<a-form-item label="默认模型" name="default">
<a-input
v-model:value="customProviderModal.data.default"
placeholder="请输入默认模型名称"
/>
</a-form-item>
<a-form-item label="API密钥" name="env">
<a-input
v-model:value="customProviderModal.data.env"
placeholder="请输入API密钥或环境变量名MY_API_KEY"
/>
<div class="form-help-text">
支持直接输入API密钥或使用环境变量名MY_API_KEY
</div>
</a-form-item>
<a-form-item label="支持的模型" name="models">
<a-textarea
v-model:value="customProviderModal.data.modelsText"
placeholder="请输入支持的模型列表,每行一个模型"
:rows="4"
/>
<div class="form-help-text">
每行输入一个模型名称例如gpt-3.5-turbo
</div>
</a-form-item>
<a-form-item label="文档地址" name="url">
<a-input
v-model:value="customProviderModal.data.url"
placeholder="请输入供应商文档地址(可选)"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { computed, reactive, watch, h } from 'vue'
import { computed, reactive, watch, h, ref } from 'vue'
import { message } from 'ant-design-vue';
import {
InfoCircleOutlined,
SettingOutlined,
DownCircleOutlined,
LoadingOutlined,
SearchOutlined
SearchOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
ApiOutlined
} from '@ant-design/icons-vue';
import { useConfigStore } from '@/stores/config';
import { modelIcons } from '@/utils/modelIcon';
import { agentApi } from '@/apis/agent_api';
import { customProviderApi } from '@/apis/system_api';
const configStore = useConfigStore();
@ -151,6 +327,14 @@ const configStore = useConfigStore();
const modelNames = computed(() => configStore.config?.model_names);
const modelStatus = computed(() => configStore.config?.model_provider_status);
//
const customProviders = computed(() => {
const providers = configStore.config?.model_names || {};
return Object.fromEntries(
Object.entries(providers).filter(([key, value]) => value.custom === true)
);
});
//
const providerConfig = reactive({
@ -166,7 +350,7 @@ const providerConfig = reactive({
// modelStatus key
const modelKeys = computed(() => {
return Object.keys(modelStatus.value || {}).filter(key => modelStatus.value[key]);
return Object.keys(modelStatus.value || {}).filter(key => modelStatus.value[key] && !customProviders.value[key]);
});
// modelStatus key
@ -294,9 +478,382 @@ const filteredModels = computed(() => {
const searchQuery = providerConfig.searchQuery.toLowerCase();
return allModels.filter(model => model.id.toLowerCase().includes(searchQuery));
});
//
const customProviderForm = ref();
const customProviderModal = reactive({
visible: false,
isEdit: false,
loading: false,
data: {
providerId: '',
name: '',
base_url: '',
default: '',
env: '',
modelsText: '',
models: [],
url: ''
}
});
//
const customProviderRules = {
providerId: [
{ required: true, message: '请输入供应商ID', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '供应商ID只能包含字母、数字、下划线和横线', trigger: 'blur' },
{
validator: (rule, value) => {
if (!value) return Promise.resolve();
// ID
if (modelNames.value && modelNames.value[value]) {
return Promise.reject('供应商ID已存在请使用其他ID');
}
return Promise.resolve();
},
trigger: 'blur'
}
],
name: [
{ required: true, message: '请输入供应商名称', trigger: 'blur' }
],
base_url: [
{ required: true, message: '请输入API地址', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL地址', trigger: 'blur' }
],
default: [
{ required: true, message: '请输入默认模型', trigger: 'blur' }
],
env: [
{ required: true, message: '请输入API密钥或环境变量', trigger: 'blur' }
]
};
// API
const maskApiKey = (apiKey) => {
if (!apiKey) return '未配置';
//
if (apiKey.startsWith('${') && apiKey.endsWith('}')) {
return apiKey;
}
// API
if (apiKey.length > 8) {
return apiKey.substring(0, 4) + '***' + apiKey.substring(apiKey.length - 4);
}
return '***';
};
//
const openAddCustomProviderModal = () => {
customProviderModal.visible = true;
customProviderModal.isEdit = false;
resetCustomProviderForm();
};
//
const openEditCustomProviderModal = (providerId, provider) => {
customProviderModal.visible = true;
customProviderModal.isEdit = true;
//
customProviderModal.data.providerId = providerId;
customProviderModal.data.name = provider.name;
customProviderModal.data.base_url = provider.base_url;
customProviderModal.data.default = provider.default;
customProviderModal.data.env = provider.env;
customProviderModal.data.models = provider.models || [];
customProviderModal.data.modelsText = (provider.models || []).join('\n');
customProviderModal.data.url = provider.url || '';
};
//
const resetCustomProviderForm = () => {
customProviderModal.data = {
providerId: '',
name: '',
base_url: '',
default: '',
env: '',
modelsText: '',
models: [],
url: ''
};
customProviderForm.value?.resetFields();
};
//
const saveCustomProvider = async () => {
try {
await customProviderForm.value.validate();
customProviderModal.loading = true;
//
const models = customProviderModal.data.modelsText
.split('\n')
.map(model => model.trim())
.filter(model => model.length > 0);
const providerData = {
name: customProviderModal.data.name,
base_url: customProviderModal.data.base_url,
default: customProviderModal.data.default,
env: customProviderModal.data.env,
models: models,
url: customProviderModal.data.url,
custom: true
};
let result;
if (customProviderModal.isEdit) {
result = await customProviderApi.updateCustomProvider(
customProviderModal.data.providerId,
providerData
);
message.success('自定义供应商更新成功');
} else {
result = await customProviderApi.addCustomProvider(
customProviderModal.data.providerId,
providerData
);
message.success(`自定义供应商 ${customProviderModal.data.providerId} 添加成功`);
}
//
customProviderModal.visible = false;
await configStore.refreshConfig();
} catch (error) {
if (error.errorFields) {
//
return;
}
// API
let errorMessage = '未知错误';
if (error.response?.data?.detail) {
errorMessage = error.response.data.detail;
} else if (error.message) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
}
message.error(`操作失败: ${errorMessage}`);
} finally {
customProviderModal.loading = false;
}
};
//
const cancelCustomProvider = () => {
customProviderModal.visible = false;
resetCustomProviderForm();
};
//
const deleteCustomProvider = async (providerId) => {
try {
const result = await customProviderApi.deleteCustomProvider(providerId);
message.success('自定义供应商删除成功');
await configStore.refreshConfig();
} catch (error) {
message.error(`删除失败: ${error.message || error.response?.data?.detail || '未知错误'}`);
}
};
//
const testCustomProvider = async (providerId, modelName) => {
try {
message.loading({ content: '正在测试连接...', key: 'test-connection', duration: 0 });
const result = await customProviderApi.testCustomProvider(providerId, modelName);
if (result.status?.status === 'available') {
message.success({ content: '连接测试成功', key: 'test-connection', duration: 2 });
} else {
message.error({
content: `连接测试失败: ${result.status?.message || '未知错误'}`,
key: 'test-connection',
duration: 3
});
}
} catch (error) {
message.error({
content: `测试失败: ${error.message || error.response?.data?.detail || '未知错误'}`,
key: 'test-connection',
duration: 3
});
}
};
</script>
<style lang="less" scoped>
//
.custom-providers-section {
margin-bottom: 24px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
}
.section-description {
margin: 0 0 16px 0;
color: var(--gray-600);
font-size: 14px;
line-height: 1.5;
}
.custom-provider-card {
border: 1px solid var(--gray-200);
background: white;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
&:hover {
border-color: var(--gray-300);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fafafa;
border-bottom: 1px solid var(--gray-200);
.provider-info {
display: flex;
align-items: center;
gap: 12px;
h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
.provider-id {
background: var(--main-color);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
}
.provider-actions {
display: flex;
gap: 8px;
.ant-btn {
display: flex;
align-items: center;
gap: 4px;
}
}
}
.card-content {
padding: 16px 20px;
.provider-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: 12px;
color: var(--gray-600);
font-weight: 500;
}
.value {
font-size: 14px;
color: var(--gray-900);
word-break: break-all;
}
}
}
}
}
.empty-state {
text-align: center;
padding: 40px 20px;
background: #fafafa;
border-radius: 8px;
border: 1px dashed var(--gray-300);
}
}
.builtin-providers-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gray-900);
}
.providers-stats {
display: flex;
gap: 12px;
font-size: 13px;
.stats-item {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
&.available {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.unavailable {
background: rgba(249, 115, 22, 0.1);
color: #ea580c;
}
}
}
}
}
//
.form-help-text {
font-size: 12px;
color: var(--gray-600);
margin-top: 4px;
line-height: 1.4;
}
.model-provider-card {
border: 1px solid var(--gray-150);
background-color: white;
@ -323,14 +880,16 @@ const filteredModels = computed(() => {
// provider
&.configured-provider {
.card-header {
.model-icon {
&.available {
filter: grayscale(0%);
}
}
.model-icon {
&.available {
position: relative;
overflow: visible;
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.05);
}
.provider-meta {
.model-count {
color: var(--gray-600);
font-weight: 500;
}
}
}
@ -339,18 +898,32 @@ const filteredModels = computed(() => {
&.unconfigured-provider {
.card-header {
background: #fafafa;
border-bottom: 1px solid var(--gray-150);
h3 {
color: var(--gray-700);
font-weight: 500;
}
.missing-keys {
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 4px;
padding: 4px 8px;
margin: 0;
.model-icon {
filter: grayscale(100%);
}
}
.missing-keys {
color: var(--gray-700);
font-size: 12px;
font-weight: 500;
& > span {
margin-left: 6px;
user-select: all;
background-color: rgba(251, 146, 60, 0.15);
color: #d97706;
padding: 3px 8px;
border-radius: 6px;
font-weight: 600;
font-size: 11px;
border: 1px solid rgba(251, 146, 60, 0.2);
}
}
}
@ -370,10 +943,47 @@ const filteredModels = computed(() => {
.model-title-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
flex-direction: column;
gap: 4px;
flex: 1;
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gray-900);
}
.provider-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.model-count {
color: var(--gray-600);
font-weight: 500;
}
.provider-id {
background: var(--gray-100);
color: var(--gray-600);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
font-family: 'Courier New', monospace;
}
.provider-link {
color: var(--gray-500);
transition: color 0.2s ease;
&:hover {
color: var(--main-color);
}
}
}
}
.model-url {
@ -390,11 +1000,11 @@ const filteredModels = computed(() => {
.model-icon {
width: 28px;
height: 28px;
position: relative;
border-radius: 6px;
overflow: hidden;
filter: grayscale(100%);
transition: filter 0.2s ease;
flex-shrink: 0;
img {
width: 100%;
@ -409,11 +1019,31 @@ const filteredModels = computed(() => {
}
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gray-900);
.expand-button, .config-button {
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
color: var(--gray-500);
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
background-color: var(--gray-50);
color: var(--gray-700);
}
.icon-wrapper {
display: inline-flex;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.rotated {
transform: rotate(180deg);
}
}
}
a {
@ -764,6 +1394,19 @@ const filteredModels = computed(() => {
}
}
//
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
//
@media (max-width: 768px) {
.model-provider-card {

View File

@ -32,11 +32,11 @@
</div>
<template #overlay>
<a-menu class="scrollable-menu">
<a-menu-item-group v-for="(item, key) in modelKeys" :key="key" :title="modelNames[item]?.name">
<a-menu-item-group v-for="(item, key) in modelKeys" :key="key" :title="modelNames[item]?.name">
<a-menu-item v-for="(model, idx) in modelNames[item]?.models" :key="`${item}-${idx}`" @click="handleSelectModel(item, model)">
{{ model }}
</a-menu-item>
</a-menu-item-group>
</a-menu-item>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>