feat: 平台管理 - 模型提供商连接测试功能
- 前端:添加"测试连接"按钮,支持实时测试 provider 可用性
- 后端:新增 TestProvider 接口,发送测试请求验证配置
- 路由:注册 POST /providers/{id}/test 端点
- 初始化:LLM Manager 优先从数据库加载 providers,失败时回退到环境变量
- .gitignore:忽略构建产物 server/bin/、server-* 和 test-* 目录
This commit is contained in:
@@ -3,7 +3,10 @@
|
||||
|
||||
# ===== Go =====
|
||||
server/server
|
||||
server/server-*
|
||||
server/tmp/
|
||||
server/bin/
|
||||
server/cmd/test-*/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Power, Trash2, Cpu, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Plus, Pencil, Power, Trash2, Cpu, CheckCircle2, XCircle, Wifi } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
|
||||
interface ProviderForm {
|
||||
@@ -55,6 +55,7 @@ export default function PlatformProvidersPage() {
|
||||
const [editing, setEditing] = useState<ProviderForm | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ModelProvider | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ["platformProviders"],
|
||||
@@ -126,6 +127,31 @@ export default function PlatformProvidersPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const testConnection = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/api/v1/platform/providers/${id}/test`),
|
||||
onSuccess: (data: any) => {
|
||||
if (data.success) {
|
||||
toast.success("连接测试成功", {
|
||||
description: data.response || "Provider 响应正常",
|
||||
});
|
||||
} else {
|
||||
toast.error("连接测试失败", {
|
||||
description: data.message || "未知错误",
|
||||
});
|
||||
}
|
||||
setTestingId(null);
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error("连接测试失败", { description: e.message });
|
||||
setTestingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTest = (id: string) => {
|
||||
setTestingId(id);
|
||||
testConnection.mutate(id);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!editing) return;
|
||||
if (!editing.name || !editing.base_url) {
|
||||
@@ -222,6 +248,16 @@ export default function PlatformProvidersPage() {
|
||||
>
|
||||
<Pencil className="h-3 w-3" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => handleTest(p.id)}
|
||||
disabled={testingId === p.id}
|
||||
>
|
||||
<Wifi className="h-3 w-3" />
|
||||
{testingId === p.id ? "测试中..." : "测试连接"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -42,14 +43,20 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H
|
||||
|
||||
// LLM Manager — direct model calls replacing Dify for chat
|
||||
llmMgr := llm.NewManager()
|
||||
if cfg.LLM.OpenAIKey != "" {
|
||||
llmMgr.Register("openai", llm.NewOpenAIProvider(cfg.LLM.OpenAIKey, cfg.LLM.OpenAIBaseURL, cfg.LLM.OpenAIModel))
|
||||
}
|
||||
if cfg.LLM.AnthropicKey != "" {
|
||||
llmMgr.Register("anthropic", llm.NewAnthropicProvider(cfg.LLM.AnthropicKey, cfg.LLM.AnthropicBaseURL, cfg.LLM.AnthropicModel))
|
||||
}
|
||||
if cfg.LLM.Provider != "" {
|
||||
llmMgr.SetFallback(cfg.LLM.Provider)
|
||||
llmMgr.SetPool(pool)
|
||||
|
||||
// 优先从数据库加载 providers(如果失败,回退到环境变量配置)
|
||||
if err := llmMgr.LoadProvidersFromDB(context.Background()); err != nil {
|
||||
// 回退:使用环境变量配置
|
||||
if cfg.LLM.OpenAIKey != "" {
|
||||
llmMgr.Register("openai", llm.NewOpenAIProvider(cfg.LLM.OpenAIKey, cfg.LLM.OpenAIBaseURL, cfg.LLM.OpenAIModel))
|
||||
}
|
||||
if cfg.LLM.AnthropicKey != "" {
|
||||
llmMgr.Register("anthropic", llm.NewAnthropicProvider(cfg.LLM.AnthropicKey, cfg.LLM.AnthropicBaseURL, cfg.LLM.AnthropicModel))
|
||||
}
|
||||
if cfg.LLM.Provider != "" {
|
||||
llmMgr.SetFallback(cfg.LLM.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// Embedding client(向量化服务,支持 DashScope / OpenAI 兼容 API)
|
||||
@@ -229,6 +236,7 @@ func newRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *redis.Client) http.H
|
||||
// 模型提供商
|
||||
r.Get("/providers", platformH.ListProviders)
|
||||
r.Post("/providers", platformH.CreateProvider)
|
||||
r.Post("/providers/{id}/test", platformH.TestProvider)
|
||||
r.Put("/providers/{id}", platformH.UpdateProvider)
|
||||
r.Delete("/providers/{id}", platformH.DeleteProvider)
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/enterprise-ai-platform/server/internal/response"
|
||||
"github.com/enterprise-ai-platform/server/pkg/llm"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -96,9 +98,9 @@ func (h *PlatformHandler) OrgRanking(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, short string
|
||||
users, apps int
|
||||
conversations, toks int64
|
||||
id, name, short string
|
||||
users, apps int
|
||||
conversations, toks int64
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &short, &users, &apps, &conversations, &toks); err != nil {
|
||||
continue
|
||||
@@ -295,12 +297,12 @@ func (h *PlatformHandler) ListAllUsers(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, email, role, status string
|
||||
avatarURL, employeeID *string
|
||||
lastLoginAt *time.Time
|
||||
loginCount int
|
||||
createdAt time.Time
|
||||
orgID, orgName, orgShort string
|
||||
id, name, email, role, status string
|
||||
avatarURL, employeeID *string
|
||||
lastLoginAt *time.Time
|
||||
loginCount int
|
||||
createdAt time.Time
|
||||
orgID, orgName, orgShort string
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &email, &avatarURL, &role, &status, &employeeID, &lastLoginAt, &loginCount, &createdAt, &orgID, &orgName, &orgShort); err != nil {
|
||||
continue
|
||||
@@ -439,14 +441,14 @@ func (h *PlatformHandler) ListAllApps(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, name, slug, status, visibility string
|
||||
desc, iconURL *string
|
||||
appType *string
|
||||
usageCount int64
|
||||
isFeatured bool
|
||||
createdAt time.Time
|
||||
catName, creatorName string
|
||||
orgID, orgName, orgShort string
|
||||
id, name, slug, status, visibility string
|
||||
desc, iconURL *string
|
||||
appType *string
|
||||
usageCount int64
|
||||
isFeatured bool
|
||||
createdAt time.Time
|
||||
catName, creatorName string
|
||||
orgID, orgName, orgShort string
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &slug, &desc, &iconURL, &appType, &status, &visibility,
|
||||
&usageCount, &isFeatured, &createdAt, &catName, &creatorName, &orgID, &orgName, &orgShort); err != nil {
|
||||
@@ -646,12 +648,12 @@ func (h *PlatformHandler) CreateProvider(w http.ResponseWriter, r *http.Request)
|
||||
func (h *PlatformHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Models json.RawMessage `json:"models"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Priority *int `json:"priority"`
|
||||
Name *string `json:"name"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Models json.RawMessage `json:"models"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Priority *int `json:"priority"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.BadRequest(w, "无效的请求格式")
|
||||
@@ -696,6 +698,92 @@ func (h *PlatformHandler) DeleteProvider(w http.ResponseWriter, r *http.Request)
|
||||
response.JSON(w, http.StatusOK, map[string]string{"message": "已删除"})
|
||||
}
|
||||
|
||||
func (h *PlatformHandler) TestProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
// 查询 provider 配置
|
||||
var baseURL, apiKey string
|
||||
var modelsJSON, configJSON []byte
|
||||
err := h.pool.QueryRow(r.Context(), `
|
||||
SELECT base_url, api_key_encrypted, models, config
|
||||
FROM model_providers
|
||||
WHERE id = $1
|
||||
`, id).Scan(&baseURL, &apiKey, &modelsJSON, &configJSON)
|
||||
if err != nil {
|
||||
response.BadRequest(w, "provider 不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(configJSON, &config); err != nil {
|
||||
response.InternalError(w, "配置解析失败")
|
||||
return
|
||||
}
|
||||
|
||||
providerType, _ := config["provider"].(string)
|
||||
if providerType == "" {
|
||||
providerType = "openai"
|
||||
}
|
||||
|
||||
// 选择测试模型:优先 config.default_model,其次取 models 列的第一个模型
|
||||
// 兼容两种模型项结构:seed 数据用 "id" 字段,前端表单用 "name" 字段作为模型标识
|
||||
var testModel string
|
||||
if dm, ok := config["default_model"].(string); ok && dm != "" {
|
||||
testModel = dm
|
||||
}
|
||||
if testModel == "" {
|
||||
var modelList []map[string]any
|
||||
if err := json.Unmarshal(modelsJSON, &modelList); err == nil && len(modelList) > 0 {
|
||||
if v, ok := modelList[0]["id"].(string); ok && v != "" {
|
||||
testModel = v
|
||||
} else if v, ok := modelList[0]["name"].(string); ok && v != "" {
|
||||
testModel = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if testModel == "" {
|
||||
testModel = "gpt-3.5-turbo" // fallback
|
||||
}
|
||||
|
||||
// 创建临时 provider 进行测试(注意:构造函数签名为 apiKey, baseURL, model,顺序不可颠倒)
|
||||
var provider llm.Provider
|
||||
switch providerType {
|
||||
case "openai":
|
||||
provider = llm.NewOpenAIProvider(apiKey, baseURL, "")
|
||||
case "anthropic":
|
||||
provider = llm.NewAnthropicProvider(apiKey, baseURL, "")
|
||||
default:
|
||||
response.BadRequest(w, "不支持的 provider 类型")
|
||||
return
|
||||
}
|
||||
|
||||
// 发送测试请求
|
||||
testReq := &llm.ChatRequest{
|
||||
Model: testModel,
|
||||
Messages: []llm.Message{
|
||||
{Role: "user", Content: "请回复:测试成功"},
|
||||
},
|
||||
MaxTokens: 20,
|
||||
Temperature: 0.1,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
result, err := provider.ChatCompletion(r.Context(), testReq)
|
||||
if err != nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("连接失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"message": "连接成功",
|
||||
"response": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 全局配额管理 ====================
|
||||
|
||||
func (h *PlatformHandler) ListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -722,14 +810,14 @@ func (h *PlatformHandler) ListQuotas(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, targetType string
|
||||
targetID *string
|
||||
modelName *string
|
||||
dailyTokens, monthlyTokens *int64
|
||||
dailyReqs *int
|
||||
isActive bool
|
||||
createdAt time.Time
|
||||
targetName string
|
||||
id, targetType string
|
||||
targetID *string
|
||||
modelName *string
|
||||
dailyTokens, monthlyTokens *int64
|
||||
dailyReqs *int
|
||||
isActive bool
|
||||
createdAt time.Time
|
||||
targetName string
|
||||
)
|
||||
if err := rows.Scan(&id, &targetType, &targetID, &modelName, &dailyTokens, &monthlyTokens,
|
||||
&dailyReqs, &isActive, &createdAt, &targetName); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user