Browse Source
Backend: - Add provider/model/key types and routes to ai.api + base.api - Generate 11 handler + logic stubs via goctl - Implement provider CRUD logic (list/create/update/delete) - Implement model admin CRUD logic (create/update/delete) - Implement API key CRUD logic (list/create/update/delete) - Add Casbin policies for key management and admin endpoints - Add menu seeds with proper icons (Cpu, Key, MessageSquare) Frontend: - Add provider/model/key TypeScript types and API client methods - Create AIModelManagementPage with provider + model tabs - Create AIKeyManagementPage with key CRUD and provider dropdown - Register routes and page titles for /ai/models and /ai/keys - Add Cpu, Key, MessageSquare icons to Sidebar icon mapmaster
34 changed files with 3055 additions and 5 deletions
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 添加API Key
|
||||
|
func AiApiKeyCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiApiKeyCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyCreate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 删除API Key
|
||||
|
func AiApiKeyDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiApiKeyDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyDelete(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取我的API Key列表
|
||||
|
func AiApiKeyListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiApiKeyListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 更新API Key
|
||||
|
func AiApiKeyUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIApiKeyUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiApiKeyUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiApiKeyUpdate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 创建AI模型
|
||||
|
func AiModelCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelCreate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 删除AI模型
|
||||
|
func AiModelDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelDelete(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 更新AI模型
|
||||
|
func AiModelUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIModelUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiModelUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiModelUpdate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 创建AI平台
|
||||
|
func AiProviderCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderCreateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderCreateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderCreate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 删除AI平台
|
||||
|
func AiProviderDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderDeleteRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderDeleteLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderDelete(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 获取AI平台列表
|
||||
|
func AiProviderListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderListRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderListLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderList(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/youruser/base/internal/logic/ai" |
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/zeromicro/go-zero/rest/httpx" |
||||
|
) |
||||
|
|
||||
|
// 更新AI平台
|
||||
|
func AiProviderUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
||||
|
return func(w http.ResponseWriter, r *http.Request) { |
||||
|
var req types.AIProviderUpdateRequest |
||||
|
if err := httpx.Parse(r, &req); err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
l := ai.NewAiProviderUpdateLogic(r.Context(), svcCtx) |
||||
|
resp, err := l.AiProviderUpdate(&req) |
||||
|
if err != nil { |
||||
|
httpx.ErrorCtx(r.Context(), w, err) |
||||
|
} else { |
||||
|
httpx.OkJsonCtx(r.Context(), w, resp) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiApiKeyCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 创建API Key
|
||||
|
func NewAiApiKeyCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyCreateLogic { |
||||
|
return &AiApiKeyCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyCreateLogic) AiApiKeyCreate(req *types.AIApiKeyCreateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
apiKey := &model.AIApiKey{ |
||||
|
ProviderId: req.ProviderId, |
||||
|
UserId: userId, |
||||
|
KeyValue: req.KeyValue, |
||||
|
IsActive: true, |
||||
|
Remark: req.Remark, |
||||
|
} |
||||
|
|
||||
|
id, err := model.AIApiKeyInsert(l.ctx, l.svcCtx.DB, apiKey) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("创建API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Retrieve the inserted record to get timestamps
|
||||
|
created, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Look up provider name
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, created.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyInfo{ |
||||
|
Id: created.Id, |
||||
|
ProviderId: created.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: created.UserId, |
||||
|
KeyPreview: maskKey(created.KeyValue), |
||||
|
IsActive: created.IsActive, |
||||
|
Remark: created.Remark, |
||||
|
CreatedAt: created.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiApiKeyDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除API Key
|
||||
|
func NewAiApiKeyDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyDeleteLogic { |
||||
|
return &AiApiKeyDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyDeleteLogic) AiApiKeyDelete(req *types.AIApiKeyDeleteRequest) (resp *types.Response, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Find existing key
|
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("API Key不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify ownership
|
||||
|
if existing.UserId != userId && existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("无权操作此API Key") |
||||
|
} |
||||
|
|
||||
|
if err = model.AIApiKeyDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, fmt.Errorf("删除API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
resp = &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "success", |
||||
|
Success: true, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,90 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiApiKeyListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取我的API Key列表
|
||||
|
func NewAiApiKeyListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyListLogic { |
||||
|
return &AiApiKeyListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyListLogic) AiApiKeyList(req *types.AIApiKeyListRequest) (resp *types.AIApiKeyListResponse, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Query keys belonging to current user or system keys (userId=0)
|
||||
|
var keys []model.AIApiKey |
||||
|
var total int64 |
||||
|
query := l.svcCtx.DB.WithContext(l.ctx).Model(&model.AIApiKey{}).Where("user_id = ? OR user_id = 0", userId) |
||||
|
if err = query.Count(&total).Error; err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
offset := (req.Page - 1) * req.PageSize |
||||
|
if offset < 0 { |
||||
|
offset = 0 |
||||
|
} |
||||
|
if err = query.Order("created_at DESC").Offset(int(offset)).Limit(int(req.PageSize)).Find(&keys).Error; err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Build provider name cache to avoid repeated queries
|
||||
|
providerCache := make(map[int64]string) |
||||
|
list := make([]types.AIApiKeyInfo, 0, len(keys)) |
||||
|
for _, key := range keys { |
||||
|
providerName := "" |
||||
|
if name, ok := providerCache[key.ProviderId]; ok { |
||||
|
providerName = name |
||||
|
} else { |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, key.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
providerCache[key.ProviderId] = providerName |
||||
|
} |
||||
|
|
||||
|
list = append(list, types.AIApiKeyInfo{ |
||||
|
Id: key.Id, |
||||
|
ProviderId: key.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: key.UserId, |
||||
|
KeyPreview: maskKey(key.KeyValue), |
||||
|
IsActive: key.IsActive, |
||||
|
Remark: key.Remark, |
||||
|
CreatedAt: key.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyListResponse{ |
||||
|
List: list, |
||||
|
Total: total, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
func maskKey(key string) string { |
||||
|
if len(key) <= 10 { |
||||
|
return "sk-***" |
||||
|
} |
||||
|
return key[:6] + "..." + key[len(key)-4:] |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiApiKeyUpdateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 更新API Key
|
||||
|
func NewAiApiKeyUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiApiKeyUpdateLogic { |
||||
|
return &AiApiKeyUpdateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiApiKeyUpdateLogic) AiApiKeyUpdate(req *types.AIApiKeyUpdateRequest) (resp *types.AIApiKeyInfo, err error) { |
||||
|
userId, _ := l.ctx.Value("userId").(int64) |
||||
|
|
||||
|
// Find existing key
|
||||
|
existing, err := model.AIApiKeyFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
if err == model.ErrNotFound { |
||||
|
return nil, fmt.Errorf("API Key不存在") |
||||
|
} |
||||
|
return nil, fmt.Errorf("查询API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Verify ownership: must be the user's own key or a system key (userId=0)
|
||||
|
if existing.UserId != userId && existing.UserId != 0 { |
||||
|
return nil, fmt.Errorf("无权更新此API Key") |
||||
|
} |
||||
|
|
||||
|
// Update fields
|
||||
|
if req.KeyValue != "" { |
||||
|
existing.KeyValue = req.KeyValue |
||||
|
} |
||||
|
existing.IsActive = req.IsActive |
||||
|
existing.Remark = req.Remark |
||||
|
|
||||
|
if err = model.AIApiKeyUpdate(l.ctx, l.svcCtx.DB, existing); err != nil { |
||||
|
return nil, fmt.Errorf("更新API Key失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Look up provider name
|
||||
|
providerName := "" |
||||
|
provider, provErr := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, existing.ProviderId) |
||||
|
if provErr == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIApiKeyInfo{ |
||||
|
Id: existing.Id, |
||||
|
ProviderId: existing.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
UserId: existing.UserId, |
||||
|
KeyPreview: maskKey(existing.KeyValue), |
||||
|
IsActive: existing.IsActive, |
||||
|
Remark: existing.Remark, |
||||
|
CreatedAt: existing.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiModelCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 创建AI模型
|
||||
|
func NewAiModelCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiModelCreateLogic { |
||||
|
return &AiModelCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiModelCreateLogic) AiModelCreate(req *types.AIModelCreateRequest) (resp *types.AIModelInfo, err error) { |
||||
|
aiModel := &model.AIModel{ |
||||
|
ProviderId: req.ProviderId, |
||||
|
ModelId: req.ModelId, |
||||
|
DisplayName: req.DisplayName, |
||||
|
InputPrice: req.InputPrice, |
||||
|
OutputPrice: req.OutputPrice, |
||||
|
MaxTokens: req.MaxTokens, |
||||
|
ContextWindow: req.ContextWindow, |
||||
|
SupportsStream: req.SupportsStream, |
||||
|
SupportsVision: req.SupportsVision, |
||||
|
IsActive: true, |
||||
|
} |
||||
|
|
||||
|
id, err := model.AIModelInsert(l.ctx, l.svcCtx.DB, aiModel) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("创建AI模型失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 查询刚创建的记录以获取完整字段
|
||||
|
aiModel, err = model.AIModelFindOne(l.ctx, l.svcCtx.DB, id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询AI模型失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 查询供应商名称
|
||||
|
providerName := "" |
||||
|
provider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, aiModel.ProviderId) |
||||
|
if err == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIModelInfo{ |
||||
|
Id: aiModel.Id, |
||||
|
ProviderId: aiModel.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
ModelId: aiModel.ModelId, |
||||
|
DisplayName: aiModel.DisplayName, |
||||
|
InputPrice: aiModel.InputPrice, |
||||
|
OutputPrice: aiModel.OutputPrice, |
||||
|
MaxTokens: aiModel.MaxTokens, |
||||
|
ContextWindow: aiModel.ContextWindow, |
||||
|
SupportsStream: aiModel.SupportsStream, |
||||
|
SupportsVision: aiModel.SupportsVision, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiModelDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除AI模型
|
||||
|
func NewAiModelDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiModelDeleteLogic { |
||||
|
return &AiModelDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiModelDeleteLogic) AiModelDelete(req *types.AIModelDeleteRequest) (resp *types.Response, err error) { |
||||
|
err = model.AIModelDelete(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
resp = &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "success", |
||||
|
Success: true, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiModelUpdateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 更新AI模型
|
||||
|
func NewAiModelUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiModelUpdateLogic { |
||||
|
return &AiModelUpdateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiModelUpdateLogic) AiModelUpdate(req *types.AIModelUpdateRequest) (resp *types.AIModelInfo, err error) { |
||||
|
// 查询现有记录
|
||||
|
aiModel, err := model.AIModelFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询AI模型失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 更新字段
|
||||
|
aiModel.DisplayName = req.DisplayName |
||||
|
aiModel.InputPrice = req.InputPrice |
||||
|
aiModel.OutputPrice = req.OutputPrice |
||||
|
aiModel.MaxTokens = req.MaxTokens |
||||
|
aiModel.ContextWindow = req.ContextWindow |
||||
|
aiModel.SupportsStream = req.SupportsStream |
||||
|
aiModel.SupportsVision = req.SupportsVision |
||||
|
aiModel.IsActive = req.IsActive |
||||
|
|
||||
|
err = model.AIModelUpdate(l.ctx, l.svcCtx.DB, aiModel) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("更新AI模型失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
// 查询供应商名称
|
||||
|
providerName := "" |
||||
|
provider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, aiModel.ProviderId) |
||||
|
if err == nil { |
||||
|
providerName = provider.DisplayName |
||||
|
} |
||||
|
|
||||
|
resp = &types.AIModelInfo{ |
||||
|
Id: aiModel.Id, |
||||
|
ProviderId: aiModel.ProviderId, |
||||
|
ProviderName: providerName, |
||||
|
ModelId: aiModel.ModelId, |
||||
|
DisplayName: aiModel.DisplayName, |
||||
|
InputPrice: aiModel.InputPrice, |
||||
|
OutputPrice: aiModel.OutputPrice, |
||||
|
MaxTokens: aiModel.MaxTokens, |
||||
|
ContextWindow: aiModel.ContextWindow, |
||||
|
SupportsStream: aiModel.SupportsStream, |
||||
|
SupportsVision: aiModel.SupportsVision, |
||||
|
} |
||||
|
|
||||
|
return resp, nil |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiProviderCreateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 创建AI平台
|
||||
|
func NewAiProviderCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiProviderCreateLogic { |
||||
|
return &AiProviderCreateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiProviderCreateLogic) AiProviderCreate(req *types.AIProviderCreateRequest) (resp *types.AIProviderInfo, err error) { |
||||
|
provider := &model.AIProvider{ |
||||
|
Name: req.Name, |
||||
|
DisplayName: req.DisplayName, |
||||
|
BaseUrl: req.BaseUrl, |
||||
|
SdkType: req.SdkType, |
||||
|
Protocol: req.Protocol, |
||||
|
IsActive: true, |
||||
|
SortOrder: req.SortOrder, |
||||
|
} |
||||
|
|
||||
|
_, err = model.AIProviderInsert(l.ctx, l.svcCtx.DB, provider) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("创建AI平台失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
return &types.AIProviderInfo{ |
||||
|
Id: provider.Id, |
||||
|
Name: provider.Name, |
||||
|
DisplayName: provider.DisplayName, |
||||
|
BaseUrl: provider.BaseUrl, |
||||
|
SdkType: provider.SdkType, |
||||
|
Protocol: provider.Protocol, |
||||
|
IsActive: provider.IsActive, |
||||
|
SortOrder: provider.SortOrder, |
||||
|
CreatedAt: provider.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: provider.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiProviderDeleteLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 删除AI平台
|
||||
|
func NewAiProviderDeleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiProviderDeleteLogic { |
||||
|
return &AiProviderDeleteLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiProviderDeleteLogic) AiProviderDelete(req *types.AIProviderDeleteRequest) (resp *types.Response, err error) { |
||||
|
if err := model.AIProviderDelete(l.ctx, l.svcCtx.DB, req.Id); err != nil { |
||||
|
return nil, fmt.Errorf("删除AI平台失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
return &types.Response{ |
||||
|
Code: 0, |
||||
|
Message: "success", |
||||
|
Success: true, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiProviderListLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 获取AI平台列表
|
||||
|
func NewAiProviderListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiProviderListLogic { |
||||
|
return &AiProviderListLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiProviderListLogic) AiProviderList(req *types.AIProviderListRequest) (resp *types.AIProviderListResponse, err error) { |
||||
|
providers, total, err := model.AIProviderFindList(l.ctx, l.svcCtx.DB, req.Page, req.PageSize) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("查询AI平台列表失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
list := make([]types.AIProviderInfo, 0, len(providers)) |
||||
|
for _, p := range providers { |
||||
|
list = append(list, types.AIProviderInfo{ |
||||
|
Id: p.Id, |
||||
|
Name: p.Name, |
||||
|
DisplayName: p.DisplayName, |
||||
|
BaseUrl: p.BaseUrl, |
||||
|
SdkType: p.SdkType, |
||||
|
Protocol: p.Protocol, |
||||
|
IsActive: p.IsActive, |
||||
|
SortOrder: p.SortOrder, |
||||
|
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: p.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return &types.AIProviderListResponse{ |
||||
|
List: list, |
||||
|
Total: total, |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,61 @@ |
|||||
|
// Code scaffolded by goctl. Safe to edit.
|
||||
|
// goctl 1.9.2
|
||||
|
|
||||
|
package ai |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"fmt" |
||||
|
|
||||
|
"github.com/youruser/base/internal/svc" |
||||
|
"github.com/youruser/base/internal/types" |
||||
|
"github.com/youruser/base/model" |
||||
|
|
||||
|
"github.com/zeromicro/go-zero/core/logx" |
||||
|
) |
||||
|
|
||||
|
type AiProviderUpdateLogic struct { |
||||
|
logx.Logger |
||||
|
ctx context.Context |
||||
|
svcCtx *svc.ServiceContext |
||||
|
} |
||||
|
|
||||
|
// 更新AI平台
|
||||
|
func NewAiProviderUpdateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AiProviderUpdateLogic { |
||||
|
return &AiProviderUpdateLogic{ |
||||
|
Logger: logx.WithContext(ctx), |
||||
|
ctx: ctx, |
||||
|
svcCtx: svcCtx, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (l *AiProviderUpdateLogic) AiProviderUpdate(req *types.AIProviderUpdateRequest) (resp *types.AIProviderInfo, err error) { |
||||
|
provider, err := model.AIProviderFindOne(l.ctx, l.svcCtx.DB, req.Id) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("AI平台不存在: %v", err) |
||||
|
} |
||||
|
|
||||
|
provider.DisplayName = req.DisplayName |
||||
|
provider.BaseUrl = req.BaseUrl |
||||
|
provider.SdkType = req.SdkType |
||||
|
provider.Protocol = req.Protocol |
||||
|
provider.IsActive = req.IsActive |
||||
|
provider.SortOrder = req.SortOrder |
||||
|
|
||||
|
if err := model.AIProviderUpdate(l.ctx, l.svcCtx.DB, provider); err != nil { |
||||
|
return nil, fmt.Errorf("更新AI平台失败: %v", err) |
||||
|
} |
||||
|
|
||||
|
return &types.AIProviderInfo{ |
||||
|
Id: provider.Id, |
||||
|
Name: provider.Name, |
||||
|
DisplayName: provider.DisplayName, |
||||
|
BaseUrl: provider.BaseUrl, |
||||
|
SdkType: provider.SdkType, |
||||
|
Protocol: provider.Protocol, |
||||
|
IsActive: provider.IsActive, |
||||
|
SortOrder: provider.SortOrder, |
||||
|
CreatedAt: provider.CreatedAt.Format("2006-01-02 15:04:05"), |
||||
|
UpdatedAt: provider.UpdatedAt.Format("2006-01-02 15:04:05"), |
||||
|
}, nil |
||||
|
} |
||||
@ -0,0 +1,469 @@ |
|||||
|
import { useState, useEffect, useCallback } from 'react' |
||||
|
import { Plus, Search, Edit2, Trash2, Key, Eye, EyeOff } from 'lucide-react' |
||||
|
import { Button } from '@/components/ui/Button' |
||||
|
import { Input } from '@/components/ui/Input' |
||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
||||
|
import { Modal } from '@/components/ui/Modal' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableRow, |
||||
|
TableHead, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/Table' |
||||
|
import type { AIApiKeyInfo, AIApiKeyCreateRequest, AIApiKeyUpdateRequest, AIProviderInfo } from '@/types' |
||||
|
import { apiClient } from '@/services/api' |
||||
|
|
||||
|
// --- Main Page Component ---
|
||||
|
|
||||
|
export function AIKeyManagementPage() { |
||||
|
const [apiKeys, setApiKeys] = useState<AIApiKeyInfo[]>([]) |
||||
|
const [isLoading, setIsLoading] = useState(true) |
||||
|
const [error, setError] = useState<string | null>(null) |
||||
|
const [searchQuery, setSearchQuery] = useState('') |
||||
|
|
||||
|
// Pagination
|
||||
|
const [currentPage, setCurrentPage] = useState(1) |
||||
|
const [pageSize] = useState(10) |
||||
|
const [total, setTotal] = useState(0) |
||||
|
|
||||
|
// Create/Edit modal
|
||||
|
const [isModalOpen, setIsModalOpen] = useState(false) |
||||
|
const [editingKey, setEditingKey] = useState<AIApiKeyInfo | null>(null) |
||||
|
const [formData, setFormData] = useState<Partial<AIApiKeyCreateRequest>>({ |
||||
|
providerId: undefined, |
||||
|
keyValue: '', |
||||
|
remark: '', |
||||
|
}) |
||||
|
const [providers, setProviders] = useState<AIProviderInfo[]>([]) |
||||
|
const [isLoadingProviders, setIsLoadingProviders] = useState(false) |
||||
|
|
||||
|
// Delete confirmation
|
||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) |
||||
|
const [keyToDelete, setKeyToDelete] = useState<AIApiKeyInfo | null>(null) |
||||
|
const [isDeleting, setIsDeleting] = useState(false) |
||||
|
|
||||
|
// Password visibility
|
||||
|
const [visibleKeys, setVisibleKeys] = useState<Set<number>>(new Set()) |
||||
|
|
||||
|
// Fetch API keys
|
||||
|
const fetchApiKeys = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoading(true) |
||||
|
setError(null) |
||||
|
const response = await apiClient.getAIApiKeys(currentPage, pageSize) |
||||
|
setApiKeys(response.list || []) |
||||
|
setTotal(response.total || 0) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch API keys:', err) |
||||
|
setError('获取密钥列表失败,请稍后重试') |
||||
|
setApiKeys([]) |
||||
|
} finally { |
||||
|
setIsLoading(false) |
||||
|
} |
||||
|
}, [currentPage, pageSize]) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
fetchApiKeys() |
||||
|
}, [fetchApiKeys]) |
||||
|
|
||||
|
// Fetch providers for the dropdown
|
||||
|
const fetchProviders = async () => { |
||||
|
try { |
||||
|
setIsLoadingProviders(true) |
||||
|
const response = await apiClient.getAIProviders() |
||||
|
setProviders(response.list || []) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch providers:', err) |
||||
|
alert('加载平台列表失败') |
||||
|
setProviders([]) |
||||
|
} finally { |
||||
|
setIsLoadingProviders(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Create / Edit ---
|
||||
|
|
||||
|
const openModal = async (key?: AIApiKeyInfo) => { |
||||
|
await fetchProviders() |
||||
|
if (key) { |
||||
|
setEditingKey(key) |
||||
|
setFormData({ |
||||
|
providerId: key.providerId, |
||||
|
keyValue: '', |
||||
|
remark: key.remark, |
||||
|
}) |
||||
|
} else { |
||||
|
setEditingKey(null) |
||||
|
setFormData({ providerId: undefined, keyValue: '', remark: '' }) |
||||
|
} |
||||
|
setIsModalOpen(true) |
||||
|
} |
||||
|
|
||||
|
const resetForm = () => { |
||||
|
setFormData({ providerId: undefined, keyValue: '', remark: '' }) |
||||
|
setEditingKey(null) |
||||
|
} |
||||
|
|
||||
|
const handleCreateKey = async () => { |
||||
|
try { |
||||
|
if (!formData.providerId || !formData.keyValue) { |
||||
|
alert('请填写必填字段') |
||||
|
return |
||||
|
} |
||||
|
await apiClient.createAIApiKey(formData as AIApiKeyCreateRequest) |
||||
|
await fetchApiKeys() |
||||
|
setIsModalOpen(false) |
||||
|
resetForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to create API key:', err) |
||||
|
alert('创建密钥失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleUpdateKey = async () => { |
||||
|
if (!editingKey) return |
||||
|
try { |
||||
|
const data: AIApiKeyUpdateRequest = { |
||||
|
keyValue: formData.keyValue || undefined, |
||||
|
isActive: formData.isActive, |
||||
|
remark: formData.remark, |
||||
|
} |
||||
|
await apiClient.updateAIApiKey(editingKey.id, data) |
||||
|
await fetchApiKeys() |
||||
|
setIsModalOpen(false) |
||||
|
resetForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to update API key:', err) |
||||
|
alert('更新密钥失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Delete ---
|
||||
|
|
||||
|
const openDeleteConfirm = (key: AIApiKeyInfo) => { |
||||
|
setKeyToDelete(key) |
||||
|
setDeleteConfirmOpen(true) |
||||
|
} |
||||
|
|
||||
|
const handleDeleteKey = async () => { |
||||
|
if (!keyToDelete) return |
||||
|
try { |
||||
|
setIsDeleting(true) |
||||
|
await apiClient.deleteAIApiKey(keyToDelete.id) |
||||
|
setDeleteConfirmOpen(false) |
||||
|
setKeyToDelete(null) |
||||
|
await fetchApiKeys() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to delete API key:', err) |
||||
|
alert('删除密钥失败') |
||||
|
} finally { |
||||
|
setIsDeleting(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Password visibility toggle ---
|
||||
|
|
||||
|
const toggleKeyVisibility = (keyId: number) => { |
||||
|
setVisibleKeys((prev) => { |
||||
|
const next = new Set(prev) |
||||
|
if (next.has(keyId)) { |
||||
|
next.delete(keyId) |
||||
|
} else { |
||||
|
next.add(keyId) |
||||
|
} |
||||
|
return next |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// --- Filtering ---
|
||||
|
|
||||
|
const filteredKeys = apiKeys.filter( |
||||
|
(key) => |
||||
|
key.providerName.toLowerCase().includes(searchQuery.toLowerCase()) || |
||||
|
(key.remark && key.remark.toLowerCase().includes(searchQuery.toLowerCase())) |
||||
|
) |
||||
|
|
||||
|
// --- Format date ---
|
||||
|
|
||||
|
const formatDate = (dateString: string) => { |
||||
|
try { |
||||
|
const date = new Date(dateString) |
||||
|
return date.toLocaleString('zh-CN', { |
||||
|
year: 'numeric', |
||||
|
month: '2-digit', |
||||
|
day: '2-digit', |
||||
|
hour: '2-digit', |
||||
|
minute: '2-digit', |
||||
|
}) |
||||
|
} catch { |
||||
|
return dateString |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Render ---
|
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 animate-fade-in"> |
||||
|
{/* Header */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
||||
|
<div className="flex-1 w-full sm:max-w-md"> |
||||
|
<Input |
||||
|
placeholder="搜索密钥..." |
||||
|
value={searchQuery} |
||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||
|
leftIcon={<Search className="h-4 w-4" />} |
||||
|
/> |
||||
|
</div> |
||||
|
<Button onClick={() => openModal()} className="whitespace-nowrap"> |
||||
|
<Plus className="h-4 w-4" /> |
||||
|
新建密钥 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Error Message */} |
||||
|
{error && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{error}</span> |
||||
|
<button onClick={fetchApiKeys} className="underline hover:text-red-300"> |
||||
|
重试 |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* API Keys Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle>密钥列表 ({filteredKeys.length})</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>平台</TableHead> |
||||
|
<TableHead>密钥预览</TableHead> |
||||
|
<TableHead>状态</TableHead> |
||||
|
<TableHead>备注</TableHead> |
||||
|
<TableHead>创建时间</TableHead> |
||||
|
<TableHead className="text-right">操作</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>加载中...</TableCell> |
||||
|
</TableRow> |
||||
|
) : filteredKeys.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>暂无数据</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
filteredKeys.map((key) => ( |
||||
|
<TableRow key={key.id}> |
||||
|
<TableCell className="font-medium text-foreground"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Key className="h-4 w-4 text-sky-400" /> |
||||
|
{key.providerName} |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="font-mono text-sm text-text-secondary"> |
||||
|
{visibleKeys.has(key.id) ? key.keyValue : key.keyPreview} |
||||
|
</span> |
||||
|
<button |
||||
|
onClick={() => toggleKeyVisibility(key.id)} |
||||
|
className="text-text-muted hover:text-foreground transition-colors" |
||||
|
title={visibleKeys.has(key.id) ? '隐藏' : '显示'} |
||||
|
> |
||||
|
{visibleKeys.has(key.id) ? ( |
||||
|
<EyeOff className="h-4 w-4" /> |
||||
|
) : ( |
||||
|
<Eye className="h-4 w-4" /> |
||||
|
)} |
||||
|
</button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
{key.isActive ? ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"> |
||||
|
启用 |
||||
|
</span> |
||||
|
) : ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/20 text-text-secondary border border-gray-500/30"> |
||||
|
禁用 |
||||
|
</span> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary max-w-xs truncate"> |
||||
|
{key.remark || '-'} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary text-sm"> |
||||
|
{formatDate(key.createdAt)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right"> |
||||
|
<div className="flex items-center justify-end gap-2"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openModal(key)} |
||||
|
title="编辑" |
||||
|
> |
||||
|
<Edit2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openDeleteConfirm(key)} |
||||
|
className="text-red-400 hover:text-red-300" |
||||
|
title="删除" |
||||
|
> |
||||
|
<Trash2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Create / Edit Modal */} |
||||
|
<Modal |
||||
|
isOpen={isModalOpen} |
||||
|
onClose={() => { |
||||
|
setIsModalOpen(false) |
||||
|
resetForm() |
||||
|
}} |
||||
|
title={editingKey ? '编辑密钥' : '新建密钥'} |
||||
|
size="md" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setIsModalOpen(false) |
||||
|
resetForm() |
||||
|
}} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button onClick={editingKey ? handleUpdateKey : handleCreateKey}> |
||||
|
{editingKey ? '保存' : '创建'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="space-y-4"> |
||||
|
{!editingKey && ( |
||||
|
<div className="space-y-2"> |
||||
|
<label className="text-sm font-medium text-foreground"> |
||||
|
平台 <span className="text-red-400">*</span> |
||||
|
</label> |
||||
|
{isLoadingProviders ? ( |
||||
|
<div className="text-text-muted text-sm">加载中...</div> |
||||
|
) : ( |
||||
|
<select |
||||
|
className="w-full px-3 py-2 bg-card border border-border-secondary rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500" |
||||
|
value={formData.providerId || ''} |
||||
|
onChange={(e) => |
||||
|
setFormData({ ...formData, providerId: Number(e.target.value) }) |
||||
|
} |
||||
|
> |
||||
|
<option value="">请选择平台</option> |
||||
|
{providers.map((provider) => ( |
||||
|
<option key={provider.id} value={provider.id}> |
||||
|
{provider.name} |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<Input |
||||
|
label={editingKey ? '密钥值' : '密钥值'} |
||||
|
placeholder={editingKey ? '留空则不更新' : '请输入 API 密钥'} |
||||
|
value={formData.keyValue} |
||||
|
onChange={(e) => setFormData({ ...formData, keyValue: e.target.value })} |
||||
|
type="password" |
||||
|
required={!editingKey} |
||||
|
/> |
||||
|
|
||||
|
{editingKey && ( |
||||
|
<div className="space-y-2"> |
||||
|
<label className="flex items-center gap-2 text-sm font-medium text-foreground cursor-pointer"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={formData.isActive ?? editingKey.isActive} |
||||
|
onChange={(e) => |
||||
|
setFormData({ ...formData, isActive: e.target.checked }) |
||||
|
} |
||||
|
className="h-4 w-4 rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500/50 focus:ring-offset-0 accent-sky-500" |
||||
|
/> |
||||
|
<span>启用</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<Input |
||||
|
label="备注" |
||||
|
placeholder="请输入备注信息" |
||||
|
value={formData.remark} |
||||
|
onChange={(e) => setFormData({ ...formData, remark: e.target.value })} |
||||
|
/> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Delete Confirmation Modal */} |
||||
|
<Modal |
||||
|
isOpen={deleteConfirmOpen} |
||||
|
onClose={() => { |
||||
|
setDeleteConfirmOpen(false) |
||||
|
setKeyToDelete(null) |
||||
|
}} |
||||
|
title="确认删除" |
||||
|
size="sm" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setDeleteConfirmOpen(false) |
||||
|
setKeyToDelete(null) |
||||
|
}} |
||||
|
disabled={isDeleting} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="destructive" |
||||
|
onClick={handleDeleteKey} |
||||
|
disabled={isDeleting} |
||||
|
> |
||||
|
{isDeleting ? '删除中...' : '确认删除'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="py-4"> |
||||
|
<p className="text-foreground"> |
||||
|
确定要删除平台{' '} |
||||
|
<span className="font-medium text-foreground">{keyToDelete?.providerName}</span>{' '} |
||||
|
的密钥吗? |
||||
|
</p> |
||||
|
<p className="text-sm text-text-muted mt-2">此操作不可恢复,请谨慎操作。</p> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,988 @@ |
|||||
|
import { useState, useEffect, useCallback } from 'react' |
||||
|
import { Plus, Search, Edit2, Trash2, Server, Cpu } from 'lucide-react' |
||||
|
import { Button } from '@/components/ui/Button' |
||||
|
import { Input } from '@/components/ui/Input' |
||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card' |
||||
|
import { Modal } from '@/components/ui/Modal' |
||||
|
import { |
||||
|
Table, |
||||
|
TableHeader, |
||||
|
TableBody, |
||||
|
TableRow, |
||||
|
TableHead, |
||||
|
TableCell, |
||||
|
} from '@/components/ui/Table' |
||||
|
import type { |
||||
|
AIProviderInfo, |
||||
|
AIProviderCreateRequest, |
||||
|
AIProviderUpdateRequest, |
||||
|
AIModelInfo, |
||||
|
AIModelCreateRequest, |
||||
|
AIModelUpdateRequest, |
||||
|
} from '@/types' |
||||
|
import { apiClient } from '@/services/api' |
||||
|
|
||||
|
// --- Main Page Component ---
|
||||
|
|
||||
|
export function AIModelManagementPage() { |
||||
|
// Tab state
|
||||
|
const [activeTab, setActiveTab] = useState<'providers' | 'models'>('providers') |
||||
|
|
||||
|
// Providers state
|
||||
|
const [providers, setProviders] = useState<AIProviderInfo[]>([]) |
||||
|
const [isLoadingProviders, setIsLoadingProviders] = useState(true) |
||||
|
const [providerError, setProviderError] = useState<string | null>(null) |
||||
|
const [providerSearchQuery, setProviderSearchQuery] = useState('') |
||||
|
const [providerPage, setProviderPage] = useState(1) |
||||
|
const [providerTotal, setProviderTotal] = useState(0) |
||||
|
const providerPageSize = 20 |
||||
|
|
||||
|
// Models state
|
||||
|
const [models, setModels] = useState<AIModelInfo[]>([]) |
||||
|
const [isLoadingModels, setIsLoadingModels] = useState(true) |
||||
|
const [modelError, setModelError] = useState<string | null>(null) |
||||
|
const [modelSearchQuery, setModelSearchQuery] = useState('') |
||||
|
|
||||
|
// Provider Create/Edit modal
|
||||
|
const [isProviderModalOpen, setIsProviderModalOpen] = useState(false) |
||||
|
const [editingProvider, setEditingProvider] = useState<AIProviderInfo | null>(null) |
||||
|
const [providerFormData, setProviderFormData] = useState<Partial<AIProviderCreateRequest & { isActive?: boolean }>>({ |
||||
|
name: '', |
||||
|
displayName: '', |
||||
|
baseUrl: '', |
||||
|
sdkType: 'openai', |
||||
|
protocol: 'openai', |
||||
|
sortOrder: 0, |
||||
|
}) |
||||
|
|
||||
|
// Model Create/Edit modal
|
||||
|
const [isModelModalOpen, setIsModelModalOpen] = useState(false) |
||||
|
const [editingModel, setEditingModel] = useState<AIModelInfo | null>(null) |
||||
|
const [modelFormData, setModelFormData] = useState<Partial<AIModelCreateRequest & { isActive?: boolean }>>({ |
||||
|
providerId: 0, |
||||
|
modelId: '', |
||||
|
displayName: '', |
||||
|
inputPrice: 0, |
||||
|
outputPrice: 0, |
||||
|
maxTokens: 0, |
||||
|
contextWindow: 0, |
||||
|
supportsStream: false, |
||||
|
supportsVision: false, |
||||
|
}) |
||||
|
|
||||
|
// Delete confirmation
|
||||
|
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] = useState(false) |
||||
|
const [providerToDelete, setProviderToDelete] = useState<AIProviderInfo | null>(null) |
||||
|
const [isDeletingProvider, setIsDeletingProvider] = useState(false) |
||||
|
|
||||
|
const [deleteModelConfirmOpen, setDeleteModelConfirmOpen] = useState(false) |
||||
|
const [modelToDelete, setModelToDelete] = useState<AIModelInfo | null>(null) |
||||
|
const [isDeletingModel, setIsDeletingModel] = useState(false) |
||||
|
|
||||
|
// --- Fetch Providers ---
|
||||
|
|
||||
|
const fetchProviders = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoadingProviders(true) |
||||
|
setProviderError(null) |
||||
|
const response = await apiClient.getAIProviders(providerPage, providerPageSize) |
||||
|
setProviders(response.list || []) |
||||
|
setProviderTotal(response.total || 0) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch providers:', err) |
||||
|
setProviderError('获取AI平台列表失败,请稍后重试') |
||||
|
setProviders([]) |
||||
|
} finally { |
||||
|
setIsLoadingProviders(false) |
||||
|
} |
||||
|
}, [providerPage, providerPageSize]) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (activeTab === 'providers') { |
||||
|
fetchProviders() |
||||
|
} |
||||
|
}, [activeTab, fetchProviders]) |
||||
|
|
||||
|
// --- Fetch Models ---
|
||||
|
|
||||
|
const fetchModels = useCallback(async () => { |
||||
|
try { |
||||
|
setIsLoadingModels(true) |
||||
|
setModelError(null) |
||||
|
const response = await apiClient.getAIModels() |
||||
|
setModels(response.list || []) |
||||
|
} catch (err) { |
||||
|
console.error('Failed to fetch models:', err) |
||||
|
setModelError('获取AI模型列表失败,请稍后重试') |
||||
|
setModels([]) |
||||
|
} finally { |
||||
|
setIsLoadingModels(false) |
||||
|
} |
||||
|
}, []) |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (activeTab === 'models') { |
||||
|
fetchModels() |
||||
|
// Also fetch providers for the dropdown
|
||||
|
if (providers.length === 0) { |
||||
|
fetchProviders() |
||||
|
} |
||||
|
} |
||||
|
}, [activeTab, fetchModels, providers.length, fetchProviders]) |
||||
|
|
||||
|
// --- Provider Create / Edit ---
|
||||
|
|
||||
|
const openProviderModal = (provider?: AIProviderInfo) => { |
||||
|
if (provider) { |
||||
|
setEditingProvider(provider) |
||||
|
setProviderFormData({ |
||||
|
name: provider.name, |
||||
|
displayName: provider.displayName, |
||||
|
baseUrl: provider.baseUrl, |
||||
|
sdkType: provider.sdkType, |
||||
|
protocol: provider.protocol, |
||||
|
sortOrder: provider.sortOrder, |
||||
|
isActive: provider.isActive, |
||||
|
}) |
||||
|
} else { |
||||
|
setEditingProvider(null) |
||||
|
setProviderFormData({ |
||||
|
name: '', |
||||
|
displayName: '', |
||||
|
baseUrl: '', |
||||
|
sdkType: 'openai', |
||||
|
protocol: 'openai', |
||||
|
sortOrder: 0, |
||||
|
}) |
||||
|
} |
||||
|
setIsProviderModalOpen(true) |
||||
|
} |
||||
|
|
||||
|
const resetProviderForm = () => { |
||||
|
setProviderFormData({ |
||||
|
name: '', |
||||
|
displayName: '', |
||||
|
baseUrl: '', |
||||
|
sdkType: 'openai', |
||||
|
protocol: 'openai', |
||||
|
sortOrder: 0, |
||||
|
}) |
||||
|
setEditingProvider(null) |
||||
|
} |
||||
|
|
||||
|
const handleCreateProvider = async () => { |
||||
|
try { |
||||
|
await apiClient.createAIProvider(providerFormData as AIProviderCreateRequest) |
||||
|
await fetchProviders() |
||||
|
setIsProviderModalOpen(false) |
||||
|
resetProviderForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to create provider:', err) |
||||
|
alert('创建AI平台失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleUpdateProvider = async () => { |
||||
|
if (!editingProvider) return |
||||
|
try { |
||||
|
const data: AIProviderUpdateRequest = { |
||||
|
displayName: providerFormData.displayName, |
||||
|
baseUrl: providerFormData.baseUrl, |
||||
|
sdkType: providerFormData.sdkType, |
||||
|
protocol: providerFormData.protocol, |
||||
|
isActive: providerFormData.isActive, |
||||
|
sortOrder: providerFormData.sortOrder, |
||||
|
} |
||||
|
await apiClient.updateAIProvider(editingProvider.id, data) |
||||
|
await fetchProviders() |
||||
|
setIsProviderModalOpen(false) |
||||
|
resetProviderForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to update provider:', err) |
||||
|
alert('更新AI平台失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Provider Delete ---
|
||||
|
|
||||
|
const openProviderDeleteConfirm = (provider: AIProviderInfo) => { |
||||
|
setProviderToDelete(provider) |
||||
|
setDeleteProviderConfirmOpen(true) |
||||
|
} |
||||
|
|
||||
|
const handleDeleteProvider = async () => { |
||||
|
if (!providerToDelete) return |
||||
|
try { |
||||
|
setIsDeletingProvider(true) |
||||
|
await apiClient.deleteAIProvider(providerToDelete.id) |
||||
|
setDeleteProviderConfirmOpen(false) |
||||
|
setProviderToDelete(null) |
||||
|
await fetchProviders() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to delete provider:', err) |
||||
|
alert('删除AI平台失败') |
||||
|
} finally { |
||||
|
setIsDeletingProvider(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Model Create / Edit ---
|
||||
|
|
||||
|
const openModelModal = (model?: AIModelInfo) => { |
||||
|
if (model) { |
||||
|
setEditingModel(model) |
||||
|
setModelFormData({ |
||||
|
providerId: model.providerId, |
||||
|
modelId: model.modelId, |
||||
|
displayName: model.displayName, |
||||
|
inputPrice: model.inputPrice, |
||||
|
outputPrice: model.outputPrice, |
||||
|
maxTokens: model.maxTokens, |
||||
|
contextWindow: model.contextWindow, |
||||
|
supportsStream: model.supportsStream, |
||||
|
supportsVision: model.supportsVision, |
||||
|
}) |
||||
|
} else { |
||||
|
setEditingModel(null) |
||||
|
setModelFormData({ |
||||
|
providerId: providers.length > 0 ? providers[0].id : 0, |
||||
|
modelId: '', |
||||
|
displayName: '', |
||||
|
inputPrice: 0, |
||||
|
outputPrice: 0, |
||||
|
maxTokens: 0, |
||||
|
contextWindow: 0, |
||||
|
supportsStream: false, |
||||
|
supportsVision: false, |
||||
|
}) |
||||
|
} |
||||
|
setIsModelModalOpen(true) |
||||
|
} |
||||
|
|
||||
|
const resetModelForm = () => { |
||||
|
setModelFormData({ |
||||
|
providerId: 0, |
||||
|
modelId: '', |
||||
|
displayName: '', |
||||
|
inputPrice: 0, |
||||
|
outputPrice: 0, |
||||
|
maxTokens: 0, |
||||
|
contextWindow: 0, |
||||
|
supportsStream: false, |
||||
|
supportsVision: false, |
||||
|
}) |
||||
|
setEditingModel(null) |
||||
|
} |
||||
|
|
||||
|
const handleCreateModel = async () => { |
||||
|
try { |
||||
|
await apiClient.createAIModel(modelFormData as AIModelCreateRequest) |
||||
|
await fetchModels() |
||||
|
setIsModelModalOpen(false) |
||||
|
resetModelForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to create model:', err) |
||||
|
alert('创建AI模型失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleUpdateModel = async () => { |
||||
|
if (!editingModel) return |
||||
|
try { |
||||
|
const data: AIModelUpdateRequest = { |
||||
|
displayName: modelFormData.displayName, |
||||
|
inputPrice: modelFormData.inputPrice, |
||||
|
outputPrice: modelFormData.outputPrice, |
||||
|
maxTokens: modelFormData.maxTokens, |
||||
|
contextWindow: modelFormData.contextWindow, |
||||
|
supportsStream: modelFormData.supportsStream, |
||||
|
supportsVision: modelFormData.supportsVision, |
||||
|
isActive: modelFormData.isActive, |
||||
|
} |
||||
|
await apiClient.updateAIModel(editingModel.id, data) |
||||
|
await fetchModels() |
||||
|
setIsModelModalOpen(false) |
||||
|
resetModelForm() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to update model:', err) |
||||
|
alert('更新AI模型失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Model Delete ---
|
||||
|
|
||||
|
const openModelDeleteConfirm = (model: AIModelInfo) => { |
||||
|
setModelToDelete(model) |
||||
|
setDeleteModelConfirmOpen(true) |
||||
|
} |
||||
|
|
||||
|
const handleDeleteModel = async () => { |
||||
|
if (!modelToDelete) return |
||||
|
try { |
||||
|
setIsDeletingModel(true) |
||||
|
await apiClient.deleteAIModel(modelToDelete.id) |
||||
|
setDeleteModelConfirmOpen(false) |
||||
|
setModelToDelete(null) |
||||
|
await fetchModels() |
||||
|
} catch (err) { |
||||
|
console.error('Failed to delete model:', err) |
||||
|
alert('删除AI模型失败') |
||||
|
} finally { |
||||
|
setIsDeletingModel(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Filtering ---
|
||||
|
|
||||
|
const filteredProviders = providers.filter( |
||||
|
(provider) => |
||||
|
provider.name.toLowerCase().includes(providerSearchQuery.toLowerCase()) || |
||||
|
provider.displayName.toLowerCase().includes(providerSearchQuery.toLowerCase()) || |
||||
|
provider.baseUrl.toLowerCase().includes(providerSearchQuery.toLowerCase()) |
||||
|
) |
||||
|
|
||||
|
const filteredModels = models.filter( |
||||
|
(model) => |
||||
|
model.modelId.toLowerCase().includes(modelSearchQuery.toLowerCase()) || |
||||
|
model.displayName.toLowerCase().includes(modelSearchQuery.toLowerCase()) || |
||||
|
model.providerName.toLowerCase().includes(modelSearchQuery.toLowerCase()) |
||||
|
) |
||||
|
|
||||
|
// --- Format currency ---
|
||||
|
const formatPrice = (price: number) => { |
||||
|
return `$${price.toFixed(2)}/1K` |
||||
|
} |
||||
|
|
||||
|
// --- Render ---
|
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6 animate-fade-in"> |
||||
|
{/* Tabs */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex gap-2 border-b border-border-secondary"> |
||||
|
<button |
||||
|
onClick={() => setActiveTab('providers')} |
||||
|
className={`px-4 py-2 -mb-px font-medium text-sm transition-colors ${ |
||||
|
activeTab === 'providers' |
||||
|
? 'border-b-2 border-sky-400 text-sky-400' |
||||
|
: 'text-text-secondary hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
<Server className="inline-block h-4 w-4 mr-1.5 -mt-0.5" /> |
||||
|
平台管理 |
||||
|
</button> |
||||
|
<button |
||||
|
onClick={() => setActiveTab('models')} |
||||
|
className={`px-4 py-2 -mb-px font-medium text-sm transition-colors ${ |
||||
|
activeTab === 'models' |
||||
|
? 'border-b-2 border-sky-400 text-sky-400' |
||||
|
: 'text-text-secondary hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
<Cpu className="inline-block h-4 w-4 mr-1.5 -mt-0.5" /> |
||||
|
模型管理 |
||||
|
</button> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Providers Tab */} |
||||
|
{activeTab === 'providers' && ( |
||||
|
<> |
||||
|
{/* Header */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
||||
|
<div className="flex-1 w-full sm:max-w-md"> |
||||
|
<Input |
||||
|
placeholder="搜索AI平台..." |
||||
|
value={providerSearchQuery} |
||||
|
onChange={(e) => setProviderSearchQuery(e.target.value)} |
||||
|
leftIcon={<Search className="h-4 w-4" />} |
||||
|
/> |
||||
|
</div> |
||||
|
<Button onClick={() => openProviderModal()} className="whitespace-nowrap"> |
||||
|
<Plus className="h-4 w-4" /> |
||||
|
新建平台 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Error Message */} |
||||
|
{providerError && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{providerError}</span> |
||||
|
<button onClick={fetchProviders} className="underline hover:text-red-300"> |
||||
|
重试 |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Providers Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle>平台列表 ({filteredProviders.length})</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>名称</TableHead> |
||||
|
<TableHead>显示名</TableHead> |
||||
|
<TableHead>基础URL</TableHead> |
||||
|
<TableHead>SDK类型</TableHead> |
||||
|
<TableHead>协议</TableHead> |
||||
|
<TableHead>状态</TableHead> |
||||
|
<TableHead>排序</TableHead> |
||||
|
<TableHead className="text-right">操作</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoadingProviders ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>加载中...</TableCell> |
||||
|
</TableRow> |
||||
|
) : filteredProviders.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>暂无数据</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
filteredProviders.map((provider) => ( |
||||
|
<TableRow key={provider.id}> |
||||
|
<TableCell className="font-medium text-foreground"> |
||||
|
{provider.name} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{provider.displayName} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary font-mono text-sm max-w-xs truncate"> |
||||
|
{provider.baseUrl} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-500/20 text-sky-400 border border-sky-500/30"> |
||||
|
{provider.sdkType} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30"> |
||||
|
{provider.protocol} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
{provider.isActive ? ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"> |
||||
|
启用 |
||||
|
</span> |
||||
|
) : ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/20 text-text-secondary border border-gray-500/30"> |
||||
|
禁用 |
||||
|
</span> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{provider.sortOrder} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right"> |
||||
|
<div className="flex items-center justify-end gap-2"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openProviderModal(provider)} |
||||
|
title="编辑" |
||||
|
> |
||||
|
<Edit2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openProviderDeleteConfirm(provider)} |
||||
|
className="text-red-400 hover:text-red-300" |
||||
|
title="删除" |
||||
|
> |
||||
|
<Trash2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</> |
||||
|
)} |
||||
|
|
||||
|
{/* Models Tab */} |
||||
|
{activeTab === 'models' && ( |
||||
|
<> |
||||
|
{/* Header */} |
||||
|
<Card> |
||||
|
<CardContent className="p-6"> |
||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> |
||||
|
<div className="flex-1 w-full sm:max-w-md"> |
||||
|
<Input |
||||
|
placeholder="搜索AI模型..." |
||||
|
value={modelSearchQuery} |
||||
|
onChange={(e) => setModelSearchQuery(e.target.value)} |
||||
|
leftIcon={<Search className="h-4 w-4" />} |
||||
|
/> |
||||
|
</div> |
||||
|
<Button onClick={() => openModelModal()} className="whitespace-nowrap"> |
||||
|
<Plus className="h-4 w-4" /> |
||||
|
新建模型 |
||||
|
</Button> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
{/* Error Message */} |
||||
|
{modelError && ( |
||||
|
<div className="p-4 bg-red-500/10 text-red-400 rounded-lg flex justify-between items-center"> |
||||
|
<span>{modelError}</span> |
||||
|
<button onClick={fetchModels} className="underline hover:text-red-300"> |
||||
|
重试 |
||||
|
</button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Models Table */} |
||||
|
<Card> |
||||
|
<CardHeader> |
||||
|
<CardTitle>模型列表 ({filteredModels.length})</CardTitle> |
||||
|
</CardHeader> |
||||
|
<CardContent> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow> |
||||
|
<TableHead>模型ID</TableHead> |
||||
|
<TableHead>显示名</TableHead> |
||||
|
<TableHead>所属平台</TableHead> |
||||
|
<TableHead>输入价格</TableHead> |
||||
|
<TableHead>输出价格</TableHead> |
||||
|
<TableHead>最大Token</TableHead> |
||||
|
<TableHead>上下文</TableHead> |
||||
|
<TableHead>流式</TableHead> |
||||
|
<TableHead>视觉</TableHead> |
||||
|
<TableHead className="text-right">操作</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoadingModels ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>加载中...</TableCell> |
||||
|
</TableRow> |
||||
|
) : filteredModels.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell>暂无数据</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
filteredModels.map((model) => ( |
||||
|
<TableRow key={model.id}> |
||||
|
<TableCell className="font-medium text-foreground font-mono text-sm"> |
||||
|
{model.modelId} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{model.displayName} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-500/20 text-sky-400 border border-sky-500/30"> |
||||
|
{model.providerName} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary font-mono text-sm"> |
||||
|
{formatPrice(model.inputPrice)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary font-mono text-sm"> |
||||
|
{formatPrice(model.outputPrice)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{model.maxTokens.toLocaleString()} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-text-secondary"> |
||||
|
{model.contextWindow.toLocaleString()} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
{model.supportsStream ? ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"> |
||||
|
✓ |
||||
|
</span> |
||||
|
) : ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/20 text-text-secondary border border-gray-500/30"> |
||||
|
- |
||||
|
</span> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
<TableCell> |
||||
|
{model.supportsVision ? ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"> |
||||
|
✓ |
||||
|
</span> |
||||
|
) : ( |
||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/20 text-text-secondary border border-gray-500/30"> |
||||
|
- |
||||
|
</span> |
||||
|
)} |
||||
|
</TableCell> |
||||
|
<TableCell className="text-right"> |
||||
|
<div className="flex items-center justify-end gap-2"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openModelModal(model)} |
||||
|
title="编辑" |
||||
|
> |
||||
|
<Edit2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => openModelDeleteConfirm(model)} |
||||
|
className="text-red-400 hover:text-red-300" |
||||
|
title="删除" |
||||
|
> |
||||
|
<Trash2 className="h-4 w-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</> |
||||
|
)} |
||||
|
|
||||
|
{/* Provider Create / Edit Modal */} |
||||
|
<Modal |
||||
|
isOpen={isProviderModalOpen} |
||||
|
onClose={() => { |
||||
|
setIsProviderModalOpen(false) |
||||
|
resetProviderForm() |
||||
|
}} |
||||
|
title={editingProvider ? '编辑AI平台' : '新建AI平台'} |
||||
|
size="md" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setIsProviderModalOpen(false) |
||||
|
resetProviderForm() |
||||
|
}} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button onClick={editingProvider ? handleUpdateProvider : handleCreateProvider}> |
||||
|
{editingProvider ? '保存' : '创建'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="space-y-4"> |
||||
|
<Input |
||||
|
label="平台名称" |
||||
|
placeholder="请输入平台名称,如 openai" |
||||
|
value={providerFormData.name} |
||||
|
onChange={(e) => setProviderFormData({ ...providerFormData, name: e.target.value })} |
||||
|
disabled={!!editingProvider} |
||||
|
/> |
||||
|
<Input |
||||
|
label="显示名称" |
||||
|
placeholder="请输入显示名称,如 OpenAI" |
||||
|
value={providerFormData.displayName} |
||||
|
onChange={(e) => |
||||
|
setProviderFormData({ ...providerFormData, displayName: e.target.value }) |
||||
|
} |
||||
|
/> |
||||
|
<Input |
||||
|
label="基础URL" |
||||
|
placeholder="请输入基础URL,如 https://api.openai.com/v1" |
||||
|
value={providerFormData.baseUrl} |
||||
|
onChange={(e) => setProviderFormData({ ...providerFormData, baseUrl: e.target.value })} |
||||
|
/> |
||||
|
<div> |
||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">SDK类型</label> |
||||
|
<select |
||||
|
value={providerFormData.sdkType} |
||||
|
onChange={(e) => setProviderFormData({ ...providerFormData, sdkType: e.target.value })} |
||||
|
className="w-full px-3 py-2 bg-card border border-border-secondary rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500/50" |
||||
|
> |
||||
|
<option value="openai">openai</option> |
||||
|
<option value="anthropic">anthropic</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div> |
||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">协议</label> |
||||
|
<select |
||||
|
value={providerFormData.protocol} |
||||
|
onChange={(e) => |
||||
|
setProviderFormData({ ...providerFormData, protocol: e.target.value }) |
||||
|
} |
||||
|
className="w-full px-3 py-2 bg-card border border-border-secondary rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500/50" |
||||
|
> |
||||
|
<option value="openai">openai</option> |
||||
|
<option value="anthropic">anthropic</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
<Input |
||||
|
label="排序" |
||||
|
type="number" |
||||
|
placeholder="请输入排序值" |
||||
|
value={providerFormData.sortOrder?.toString() || '0'} |
||||
|
onChange={(e) => |
||||
|
setProviderFormData({ ...providerFormData, sortOrder: parseInt(e.target.value) || 0 }) |
||||
|
} |
||||
|
/> |
||||
|
{editingProvider && ( |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
id="provider-active" |
||||
|
checked={providerFormData.isActive || false} |
||||
|
onChange={(e) => |
||||
|
setProviderFormData({ ...providerFormData, isActive: e.target.checked }) |
||||
|
} |
||||
|
className="h-4 w-4 rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500/50 focus:ring-offset-0 accent-sky-500" |
||||
|
/> |
||||
|
<label htmlFor="provider-active" className="text-sm text-foreground cursor-pointer"> |
||||
|
启用状态 |
||||
|
</label> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Model Create / Edit Modal */} |
||||
|
<Modal |
||||
|
isOpen={isModelModalOpen} |
||||
|
onClose={() => { |
||||
|
setIsModelModalOpen(false) |
||||
|
resetModelForm() |
||||
|
}} |
||||
|
title={editingModel ? '编辑AI模型' : '新建AI模型'} |
||||
|
size="md" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setIsModelModalOpen(false) |
||||
|
resetModelForm() |
||||
|
}} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button onClick={editingModel ? handleUpdateModel : handleCreateModel}> |
||||
|
{editingModel ? '保存' : '创建'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="space-y-4"> |
||||
|
{!editingModel && ( |
||||
|
<div> |
||||
|
<label className="block text-sm font-medium text-foreground mb-1.5">所属平台</label> |
||||
|
<select |
||||
|
value={modelFormData.providerId} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, providerId: parseInt(e.target.value) }) |
||||
|
} |
||||
|
className="w-full px-3 py-2 bg-card border border-border-secondary rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-sky-500/50" |
||||
|
> |
||||
|
{providers.map((provider) => ( |
||||
|
<option key={provider.id} value={provider.id}> |
||||
|
{provider.displayName} |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</div> |
||||
|
)} |
||||
|
<Input |
||||
|
label="模型ID" |
||||
|
placeholder="请输入模型ID,如 gpt-4" |
||||
|
value={modelFormData.modelId} |
||||
|
onChange={(e) => setModelFormData({ ...modelFormData, modelId: e.target.value })} |
||||
|
disabled={!!editingModel} |
||||
|
/> |
||||
|
<Input |
||||
|
label="显示名称" |
||||
|
placeholder="请输入显示名称,如 GPT-4" |
||||
|
value={modelFormData.displayName} |
||||
|
onChange={(e) => setModelFormData({ ...modelFormData, displayName: e.target.value })} |
||||
|
/> |
||||
|
<Input |
||||
|
label="输入价格 ($/1K tokens)" |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
placeholder="请输入输入价格" |
||||
|
value={modelFormData.inputPrice?.toString() || '0'} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, inputPrice: parseFloat(e.target.value) || 0 }) |
||||
|
} |
||||
|
/> |
||||
|
<Input |
||||
|
label="输出价格 ($/1K tokens)" |
||||
|
type="number" |
||||
|
step="0.01" |
||||
|
placeholder="请输入输出价格" |
||||
|
value={modelFormData.outputPrice?.toString() || '0'} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, outputPrice: parseFloat(e.target.value) || 0 }) |
||||
|
} |
||||
|
/> |
||||
|
<Input |
||||
|
label="最大Token" |
||||
|
type="number" |
||||
|
placeholder="请输入最大Token数" |
||||
|
value={modelFormData.maxTokens?.toString() || '0'} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, maxTokens: parseInt(e.target.value) || 0 }) |
||||
|
} |
||||
|
/> |
||||
|
<Input |
||||
|
label="上下文窗口" |
||||
|
type="number" |
||||
|
placeholder="请输入上下文窗口大小" |
||||
|
value={modelFormData.contextWindow?.toString() || '0'} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ |
||||
|
...modelFormData, |
||||
|
contextWindow: parseInt(e.target.value) || 0, |
||||
|
}) |
||||
|
} |
||||
|
/> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
id="model-stream" |
||||
|
checked={modelFormData.supportsStream || false} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, supportsStream: e.target.checked }) |
||||
|
} |
||||
|
className="h-4 w-4 rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500/50 focus:ring-offset-0 accent-sky-500" |
||||
|
/> |
||||
|
<label htmlFor="model-stream" className="text-sm text-foreground cursor-pointer"> |
||||
|
支持流式输出 |
||||
|
</label> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
id="model-vision" |
||||
|
checked={modelFormData.supportsVision || false} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, supportsVision: e.target.checked }) |
||||
|
} |
||||
|
className="h-4 w-4 rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500/50 focus:ring-offset-0 accent-sky-500" |
||||
|
/> |
||||
|
<label htmlFor="model-vision" className="text-sm text-foreground cursor-pointer"> |
||||
|
支持视觉功能 |
||||
|
</label> |
||||
|
</div> |
||||
|
{editingModel && ( |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
id="model-active" |
||||
|
checked={modelFormData.isActive || false} |
||||
|
onChange={(e) => |
||||
|
setModelFormData({ ...modelFormData, isActive: e.target.checked }) |
||||
|
} |
||||
|
className="h-4 w-4 rounded border-border-secondary bg-card text-sky-500 focus:ring-sky-500/50 focus:ring-offset-0 accent-sky-500" |
||||
|
/> |
||||
|
<label htmlFor="model-active" className="text-sm text-foreground cursor-pointer"> |
||||
|
启用状态 |
||||
|
</label> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Provider Delete Confirmation Modal */} |
||||
|
<Modal |
||||
|
isOpen={deleteProviderConfirmOpen} |
||||
|
onClose={() => { |
||||
|
setDeleteProviderConfirmOpen(false) |
||||
|
setProviderToDelete(null) |
||||
|
}} |
||||
|
title="确认删除" |
||||
|
size="sm" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setDeleteProviderConfirmOpen(false) |
||||
|
setProviderToDelete(null) |
||||
|
}} |
||||
|
disabled={isDeletingProvider} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="destructive" |
||||
|
onClick={handleDeleteProvider} |
||||
|
disabled={isDeletingProvider} |
||||
|
> |
||||
|
{isDeletingProvider ? '删除中...' : '确认删除'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="py-4"> |
||||
|
<p className="text-foreground"> |
||||
|
确定要删除平台{' '} |
||||
|
<span className="font-medium text-foreground">{providerToDelete?.displayName}</span>{' '} |
||||
|
吗? |
||||
|
</p> |
||||
|
<p className="text-sm text-text-muted mt-2">此操作不可恢复,请谨慎操作。</p> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
|
||||
|
{/* Model Delete Confirmation Modal */} |
||||
|
<Modal |
||||
|
isOpen={deleteModelConfirmOpen} |
||||
|
onClose={() => { |
||||
|
setDeleteModelConfirmOpen(false) |
||||
|
setModelToDelete(null) |
||||
|
}} |
||||
|
title="确认删除" |
||||
|
size="sm" |
||||
|
footer={ |
||||
|
<> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
setDeleteModelConfirmOpen(false) |
||||
|
setModelToDelete(null) |
||||
|
}} |
||||
|
disabled={isDeletingModel} |
||||
|
> |
||||
|
取消 |
||||
|
</Button> |
||||
|
<Button variant="destructive" onClick={handleDeleteModel} disabled={isDeletingModel}> |
||||
|
{isDeletingModel ? '删除中...' : '确认删除'} |
||||
|
</Button> |
||||
|
</> |
||||
|
} |
||||
|
> |
||||
|
<div className="py-4"> |
||||
|
<p className="text-foreground"> |
||||
|
确定要删除模型{' '} |
||||
|
<span className="font-medium text-foreground">{modelToDelete?.displayName}</span> 吗? |
||||
|
</p> |
||||
|
<p className="text-sm text-text-muted mt-2">此操作不可恢复,请谨慎操作。</p> |
||||
|
</div> |
||||
|
</Modal> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
Loading…
Reference in new issue