chore: commit pending gateway changes
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListAccessRules(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list access rules failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list access rules failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.AccessRuleInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validAccessRuleInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "subject, resource and effect are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateAccessRule(r.Context(), input)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "access rule already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create access rule failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create access rule failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.AccessRuleBatchInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validAccessRuleBatchInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "subject, effect and resources are required")
|
||||
return
|
||||
}
|
||||
items, err := s.store.BatchAccessRules(r.Context(), input)
|
||||
if err != nil {
|
||||
s.logger.Error("batch access rules failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "batch access rules failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) updateAccessRule(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.AccessRuleInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validAccessRuleInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "subject, resource and effect are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateAccessRule(r.Context(), r.PathValue("ruleID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "access rule not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "access rule already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update access rule failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update access rule failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteAccessRule(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteAccessRule(r.Context(), r.PathValue("ruleID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "access rule not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete access rule failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete access rule failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validAccessRuleInput(input store.AccessRuleInput) bool {
|
||||
return validOneOf(input.SubjectType, "user_group", "tenant", "user", "api_key") &&
|
||||
strings.TrimSpace(input.SubjectID) != "" &&
|
||||
validOneOf(input.ResourceType, "platform", "platform_model", "base_model") &&
|
||||
strings.TrimSpace(input.ResourceID) != "" &&
|
||||
validOneOf(input.Effect, "allow", "deny") &&
|
||||
(input.Status == "" || validOneOf(input.Status, "active", "disabled"))
|
||||
}
|
||||
|
||||
func validAccessRuleBatchInput(input store.AccessRuleBatchInput) bool {
|
||||
if !validOneOf(input.SubjectType, "user_group", "tenant", "user", "api_key") ||
|
||||
strings.TrimSpace(input.SubjectID) == "" ||
|
||||
!validOneOf(input.Effect, "allow", "deny") {
|
||||
return false
|
||||
}
|
||||
if len(input.UpsertResources) == 0 && len(input.DeleteResources) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, resource := range append(input.UpsertResources, input.DeleteResources...) {
|
||||
if !validOneOf(resource.ResourceType, "platform", "platform_model", "base_model") ||
|
||||
strings.TrimSpace(resource.ResourceID) == "" ||
|
||||
(resource.Status != "" && !validOneOf(resource.Status, "active", "disabled")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validOneOf(value string, allowed ...string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -141,6 +142,34 @@ func (s *Server) updateBaseModel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) resetBaseModel(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := s.store.ResetBaseModelToDefault(r.Context(), r.PathValue("baseModelID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "base model not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrProtectedDefault) {
|
||||
writeError(w, http.StatusConflict, "base model has no system default snapshot")
|
||||
return
|
||||
}
|
||||
s.logger.Error("reset base model failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "reset base model failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) resetAllBaseModels(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ResetAllBaseModelsToDefault(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("reset all base models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "reset all base models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteBaseModel(r.Context(), r.PathValue("baseModelID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
@@ -157,5 +186,5 @@ func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
|
||||
func validBaseModelInput(input store.BaseModelInput) bool {
|
||||
return strings.TrimSpace(input.ProviderKey) != "" &&
|
||||
strings.TrimSpace(input.ProviderModelName) != "" &&
|
||||
strings.TrimSpace(input.ModelType) != ""
|
||||
len(input.ModelType) > 0
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
input.Provider = strings.TrimSpace(input.Provider)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.InternalName = strings.TrimSpace(input.InternalName)
|
||||
if input.Provider == "" || input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "provider and name are required")
|
||||
return
|
||||
@@ -164,6 +167,52 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, platform)
|
||||
}
|
||||
|
||||
func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.CreatePlatformInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
input.Provider = strings.TrimSpace(input.Provider)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.InternalName = strings.TrimSpace(input.InternalName)
|
||||
if input.Provider == "" || input.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "provider and name are required")
|
||||
return
|
||||
}
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "platform not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "platform key already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update platform failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update platform failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, platform)
|
||||
}
|
||||
|
||||
func (s *Server) deletePlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeletePlatform(r.Context(), r.PathValue("platformID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "platform not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete platform failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete platform failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.CreatePlatformModelInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
@@ -190,6 +239,47 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, model)
|
||||
}
|
||||
|
||||
func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) {
|
||||
platformID := r.PathValue("platformID")
|
||||
if platformID == "" {
|
||||
writeError(w, http.StatusBadRequest, "platformId is required")
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Models []store.CreatePlatformModelInput `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
|
||||
models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "base model not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("replace platform models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "replace platform models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
}
|
||||
|
||||
func (s *Server) deletePlatformModel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeletePlatformModel(r.Context(), r.PathValue("modelID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "platform model not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete platform model failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete platform model failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
models, err := s.store.ListModels(r.Context())
|
||||
if err != nil {
|
||||
@@ -200,6 +290,17 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
}
|
||||
|
||||
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAccessiblePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
s.logger.Error("list playable models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list playable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": models})
|
||||
}
|
||||
|
||||
func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListPricingRules(r.Context())
|
||||
if err != nil {
|
||||
@@ -251,6 +352,21 @@ func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
items, err := s.store.ListPlayableAPIKeys(r.Context(), user)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Error("list playable api keys failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list playable api keys failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
var input store.CreateAPIKeyInput
|
||||
@@ -359,19 +475,38 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
writeError(w, http.StatusInternalServerError, "create task failed")
|
||||
return
|
||||
}
|
||||
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||
if compatible {
|
||||
if runErr != nil {
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error())
|
||||
if boolValue(body, "stream") {
|
||||
flusher := prepareCompatibleStream(w)
|
||||
result, runErr := s.runner.ExecuteStream(r.Context(), task, user, func(delta string) error {
|
||||
writeCompatibleDelta(w, kind, model, delta)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if runErr != nil {
|
||||
sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
writeCompatibleDone(w, kind, model, result.Output)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
if boolValue(body, "stream") {
|
||||
writeCompatibleStream(w, kind, model, result.Output)
|
||||
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||
if runErr != nil {
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result.Output)
|
||||
return
|
||||
}
|
||||
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||
if runErr != nil {
|
||||
s.logger.Warn("task completed with failure", "kind", kind, "taskId", task.ID, "error", runErr)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.GatewayTenantInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validTenantInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "tenantKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateTenant(r.Context(), input)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "tenant key or external tenant id already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create tenant failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create tenant failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.GatewayTenantInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validTenantInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "tenantKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateTenant(r.Context(), r.PathValue("tenantID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "tenant not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "tenant key or external tenant id already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update tenant failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update tenant failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteTenant(r.Context(), r.PathValue("tenantID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "tenant not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete tenant failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete tenant failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) createGatewayUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.GatewayUserInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validGatewayUserInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
if !validOptionalPassword(input.Password) {
|
||||
writeError(w, http.StatusBadRequest, store.ErrWeakPassword.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateGatewayUser(r.Context(), input)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "user key, email or external user id already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create gateway user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create gateway user failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateGatewayUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.GatewayUserInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validGatewayUserInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "username is required")
|
||||
return
|
||||
}
|
||||
if !validOptionalPassword(input.Password) {
|
||||
writeError(w, http.StatusBadRequest, store.ErrWeakPassword.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateGatewayUser(r.Context(), r.PathValue("userID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "user key, email or external user id already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update gateway user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update gateway user failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteGatewayUser(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteGatewayUser(r.Context(), r.PathValue("userID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete gateway user failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete gateway user failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) createUserGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.UserGroupInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validUserGroupInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "groupKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateUserGroup(r.Context(), input)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "user group key already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create user group failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create user group failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateUserGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.UserGroupInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validUserGroupInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "groupKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateUserGroup(r.Context(), r.PathValue("groupID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "user group not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "user group key already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update user group failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update user group failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteUserGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteUserGroup(r.Context(), r.PathValue("groupID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "user group not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete user group failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete user group failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validTenantInput(input store.GatewayTenantInput) bool {
|
||||
return strings.TrimSpace(input.TenantKey) != "" && strings.TrimSpace(input.Name) != ""
|
||||
}
|
||||
|
||||
func validGatewayUserInput(input store.GatewayUserInput) bool {
|
||||
return strings.TrimSpace(input.Username) != ""
|
||||
}
|
||||
|
||||
func validOptionalPassword(password string) bool {
|
||||
password = strings.TrimSpace(password)
|
||||
return password == "" || len(password) >= 8
|
||||
}
|
||||
|
||||
func validUserGroupInput(input store.UserGroupInput) bool {
|
||||
return strings.TrimSpace(input.GroupKey) != "" && strings.TrimSpace(input.Name) != ""
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -74,6 +75,10 @@ func (s *Server) deletePricingRuleSet(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "pricing rule set not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrProtectedDefault) {
|
||||
writeError(w, http.StatusForbidden, "default pricing rule set cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete pricing rule set failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete pricing rule set failed")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) listRuntimePolicySets(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListRuntimePolicySets(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("list runtime policy sets failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list runtime policy sets failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) createRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.RuntimePolicySetInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validRuntimePolicyInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "policyKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateRuntimePolicySet(r.Context(), input)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "runtime policy key already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("create runtime policy set failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "create runtime policy set failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
||||
var input store.RuntimePolicySetInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
if !validRuntimePolicyInput(input) {
|
||||
writeError(w, http.StatusBadRequest, "policyKey and name are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateRuntimePolicySet(r.Context(), r.PathValue("policySetID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "runtime policy set not found")
|
||||
return
|
||||
}
|
||||
if store.IsUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "runtime policy key already exists")
|
||||
return
|
||||
}
|
||||
s.logger.Error("update runtime policy set failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "update runtime policy set failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteRuntimePolicySet(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteRuntimePolicySet(r.Context(), r.PathValue("policySetID")); err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "runtime policy set not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrProtectedDefault) {
|
||||
writeError(w, http.StatusForbidden, "default runtime policy set cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.logger.Error("delete runtime policy set failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "delete runtime policy set failed")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validRuntimePolicyInput(input store.RuntimePolicySetInput) bool {
|
||||
return strings.TrimSpace(input.PolicyKey) != "" && strings.TrimSpace(input.Name) != ""
|
||||
}
|
||||
@@ -44,25 +44,51 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("DELETE /api/v1/catalog/providers/{providerID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteCatalogProvider)))
|
||||
mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels)))
|
||||
mux.Handle("POST /api/v1/catalog/base-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createBaseModel)))
|
||||
mux.Handle("POST /api/v1/catalog/base-models/reset-all", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetAllBaseModels)))
|
||||
mux.Handle("PATCH /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateBaseModel)))
|
||||
mux.Handle("POST /api/v1/catalog/base-models/{baseModelID}/reset", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetBaseModel)))
|
||||
mux.Handle("DELETE /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteBaseModel)))
|
||||
mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants)))
|
||||
mux.Handle("POST /api/v1/tenants", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createTenant)))
|
||||
mux.Handle("PATCH /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateTenant)))
|
||||
mux.Handle("DELETE /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteTenant)))
|
||||
mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers)))
|
||||
mux.Handle("POST /api/v1/users", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser)))
|
||||
mux.Handle("PATCH /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser)))
|
||||
mux.Handle("DELETE /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser)))
|
||||
mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups)))
|
||||
mux.Handle("POST /api/v1/user-groups", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createUserGroup)))
|
||||
mux.Handle("PATCH /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup)))
|
||||
mux.Handle("DELETE /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup)))
|
||||
mux.Handle("GET /api/v1/access-rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listAccessRules)))
|
||||
mux.Handle("POST /api/v1/access-rules", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createAccessRule)))
|
||||
mux.Handle("POST /api/v1/access-rules/batch", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules)))
|
||||
mux.Handle("PATCH /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule)))
|
||||
mux.Handle("DELETE /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule)))
|
||||
mux.Handle("GET /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
|
||||
mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
|
||||
mux.Handle("GET /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
|
||||
mux.Handle("POST /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
|
||||
mux.Handle("PATCH /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet)))
|
||||
mux.Handle("DELETE /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet)))
|
||||
mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing)))
|
||||
mux.Handle("GET /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets)))
|
||||
mux.Handle("POST /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
|
||||
mux.Handle("PATCH /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
|
||||
mux.Handle("DELETE /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
||||
mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
||||
mux.Handle("PATCH /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
|
||||
mux.Handle("DELETE /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatform)))
|
||||
mux.Handle("PUT /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.replacePlatformModels)))
|
||||
mux.Handle("POST /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel)))
|
||||
mux.Handle("POST /api/v1/platform-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel)))
|
||||
mux.Handle("DELETE /api/v1/platform-models/{modelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel)))
|
||||
mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels)))
|
||||
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", false)))
|
||||
mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false)))
|
||||
@@ -91,7 +117,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -2,10 +2,42 @@ package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) {
|
||||
func prepareCompatibleStream(w http.ResponseWriter) http.Flusher {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
return flusher
|
||||
}
|
||||
|
||||
func writeCompatibleDelta(w http.ResponseWriter, kind string, model string, content string) {
|
||||
if kind == "responses" {
|
||||
sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": content})
|
||||
return
|
||||
}
|
||||
sendSSE(w, "message", map[string]any{
|
||||
"id": "chatcmpl-stream",
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": content}, "finish_reason": nil}},
|
||||
})
|
||||
}
|
||||
|
||||
func writeCompatibleDone(w http.ResponseWriter, kind string, model string, output map[string]any) {
|
||||
if kind == "responses" {
|
||||
sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output})
|
||||
return
|
||||
}
|
||||
sendSSE(w, "message", map[string]any{
|
||||
"id": firstString(output["id"], "chatcmpl-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"model": model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
|
||||
})
|
||||
}
|
||||
|
||||
func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) {
|
||||
prepareCompatibleStream(w)
|
||||
content := extractOutputText(output)
|
||||
if content == "" {
|
||||
content = "done"
|
||||
@@ -29,6 +61,13 @@ func writeCompatibleStream(w http.ResponseWriter, kind string, model string, out
|
||||
})
|
||||
}
|
||||
|
||||
func firstString(value any, fallback string) string {
|
||||
if text, ok := value.(string); ok && text != "" {
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func extractOutputText(output map[string]any) string {
|
||||
if text, ok := output["output_text"].(string); ok {
|
||||
return text
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type AccessRuleInput struct {
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Effect string `json:"effect"`
|
||||
Priority int `json:"priority"`
|
||||
MinPermissionLevel int `json:"minPermissionLevel"`
|
||||
Conditions map[string]any `json:"conditions"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AccessRuleResourceInput struct {
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Priority int `json:"priority"`
|
||||
MinPermissionLevel int `json:"minPermissionLevel"`
|
||||
Conditions map[string]any `json:"conditions"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AccessRuleBatchInput struct {
|
||||
SubjectType string `json:"subjectType"`
|
||||
SubjectID string `json:"subjectId"`
|
||||
Effect string `json:"effect"`
|
||||
UpsertResources []AccessRuleResourceInput `json:"upsertResources"`
|
||||
DeleteResources []AccessRuleResourceInput `json:"deleteResources"`
|
||||
}
|
||||
|
||||
type accessRuleResource struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
ORDER BY resource_type ASC, priority ASC, subject_type ASC, created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateAccessRule(ctx context.Context, input AccessRuleInput) (AccessRule, error) {
|
||||
input = normalizeAccessRuleInput(input)
|
||||
conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanAccessRule(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_access_rules (
|
||||
subject_type, subject_id, resource_type, resource_id, effect, priority,
|
||||
min_permission_level, conditions, metadata, status
|
||||
)
|
||||
VALUES ($1, $2::uuid, $3, $4::uuid, $5, $6, $7, $8::jsonb, $9::jsonb, $10)
|
||||
RETURNING `+accessRuleColumns,
|
||||
input.SubjectType, input.SubjectID, input.ResourceType, input.ResourceID, input.Effect,
|
||||
input.Priority, input.MinPermissionLevel, string(conditions), string(metadata), input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAccessRule(ctx context.Context, id string, input AccessRuleInput) (AccessRule, error) {
|
||||
input = normalizeAccessRuleInput(input)
|
||||
conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanAccessRule(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_access_rules
|
||||
SET subject_type = $2,
|
||||
subject_id = $3::uuid,
|
||||
resource_type = $4,
|
||||
resource_id = $5::uuid,
|
||||
effect = $6,
|
||||
priority = $7,
|
||||
min_permission_level = $8,
|
||||
conditions = $9::jsonb,
|
||||
metadata = $10::jsonb,
|
||||
status = $11,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+accessRuleColumns,
|
||||
id, input.SubjectType, input.SubjectID, input.ResourceType, input.ResourceID, input.Effect,
|
||||
input.Priority, input.MinPermissionLevel, string(conditions), string(metadata), input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAccessRule(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM gateway_access_rules WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) BatchAccessRules(ctx context.Context, input AccessRuleBatchInput) ([]AccessRule, error) {
|
||||
input = normalizeAccessRuleBatchInput(input)
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
for _, resource := range dedupeAccessRuleResources(input.DeleteResources) {
|
||||
resource = normalizeAccessRuleResource(resource, input.Effect)
|
||||
if resource.ResourceType == "" || resource.ResourceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM gateway_access_rules
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2::uuid
|
||||
AND effect = $3
|
||||
AND resource_type = $4
|
||||
AND resource_id = $5::uuid`,
|
||||
input.SubjectType, input.SubjectID, input.Effect, resource.ResourceType, resource.ResourceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range dedupeAccessRuleResources(input.UpsertResources) {
|
||||
resource = normalizeAccessRuleResource(resource, input.Effect)
|
||||
if resource.ResourceType == "" || resource.ResourceID == "" {
|
||||
continue
|
||||
}
|
||||
oppositeEffect := "deny"
|
||||
if input.Effect == "deny" {
|
||||
oppositeEffect = "allow"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM gateway_access_rules
|
||||
WHERE subject_type = $1
|
||||
AND subject_id = $2::uuid
|
||||
AND effect = $3
|
||||
AND resource_type = $4
|
||||
AND resource_id = $5::uuid`,
|
||||
input.SubjectType, input.SubjectID, oppositeEffect, resource.ResourceType, resource.ResourceID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conditions, _ := json.Marshal(emptyObjectIfNil(resource.Conditions))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(resource.Metadata))
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO gateway_access_rules (
|
||||
subject_type, subject_id, resource_type, resource_id, effect, priority,
|
||||
min_permission_level, conditions, metadata, status
|
||||
)
|
||||
VALUES ($1, $2::uuid, $3, $4::uuid, $5, $6, $7, $8::jsonb, $9::jsonb, $10)
|
||||
ON CONFLICT (subject_type, subject_id, resource_type, resource_id, effect)
|
||||
DO UPDATE SET priority = EXCLUDED.priority,
|
||||
min_permission_level = EXCLUDED.min_permission_level,
|
||||
conditions = EXCLUDED.conditions,
|
||||
metadata = EXCLUDED.metadata,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = now()`,
|
||||
input.SubjectType, input.SubjectID, resource.ResourceType, resource.ResourceID, input.Effect,
|
||||
resource.Priority, resource.MinPermissionLevel, string(conditions), string(metadata), resource.Status,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListAccessRules(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) {
|
||||
if len(candidates) == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
resources := candidateAccessResources(candidates)
|
||||
if len(resources) == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
level = auth.PermissionLevel(user.Roles)
|
||||
}
|
||||
filtered := candidates[:0]
|
||||
for _, candidate := range candidates {
|
||||
if candidateAllowedByAccessRules(candidate, rules, subjects, level) {
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
||||
models, err := s.ListModels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
platforms, err := s.ListPlatforms(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabledPlatforms := map[string]bool{}
|
||||
for _, platform := range platforms {
|
||||
if platform.Status == "enabled" {
|
||||
enabledPlatforms[platform.ID] = true
|
||||
}
|
||||
}
|
||||
enabled := make([]PlatformModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if model.Enabled && enabledPlatforms[model.PlatformID] {
|
||||
enabled = append(enabled, model)
|
||||
}
|
||||
}
|
||||
return s.filterPlatformModelsByAccessRules(ctx, user, enabled)
|
||||
}
|
||||
|
||||
func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) {
|
||||
if len(models) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
resources := platformModelAccessResources(models)
|
||||
if len(resources) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
rules, err := s.listActiveAccessRulesForResources(ctx, resources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
level = auth.PermissionLevel(user.Roles)
|
||||
}
|
||||
filtered := models[:0]
|
||||
for _, model := range models {
|
||||
if platformModelAllowedByAccessRules(model, rules, subjects, level) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
|
||||
values := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.Type == "" || resource.ID == "" {
|
||||
continue
|
||||
}
|
||||
values = append(values, resource.Type+":"+resource.ID)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+accessRuleColumns+`
|
||||
FROM gateway_access_rules
|
||||
WHERE status = 'active'
|
||||
AND (resource_type || ':' || resource_id::text) = ANY($1)
|
||||
ORDER BY priority ASC, created_at ASC`, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
rules := make([]AccessRule, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanAccessRule(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, item)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
func candidateAccessResources(candidates []RuntimeModelCandidate) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(candidates)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
add("platform", candidate.PlatformID)
|
||||
add("platform_model", candidate.PlatformModelID)
|
||||
add("base_model", candidate.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func platformModelAccessResources(models []PlatformModel) []accessRuleResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]accessRuleResource, 0, len(models)*3)
|
||||
add := func(resourceType string, resourceID string) {
|
||||
key := resourceType + ":" + resourceID
|
||||
if resourceID == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, accessRuleResource{Type: resourceType, ID: resourceID})
|
||||
}
|
||||
for _, model := range models {
|
||||
add("platform", model.PlatformID)
|
||||
add("platform_model", model.ID)
|
||||
add("base_model", model.BaseModelID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func candidateAllowedByAccessRules(candidate RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + candidate.PlatformID: true,
|
||||
"platform_model:" + candidate.PlatformModelID: true,
|
||||
"base_model:" + candidate.BaseModelID: candidate.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func platformModelAllowedByAccessRules(model PlatformModel, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool {
|
||||
resourceKeys := map[string]bool{
|
||||
"platform:" + model.PlatformID: true,
|
||||
"platform_model:" + model.ID: true,
|
||||
"base_model:" + model.BaseModelID: model.BaseModelID != "",
|
||||
}
|
||||
allowByResource := map[string]bool{}
|
||||
matchedAllowByResource := map[string]bool{}
|
||||
for _, rule := range rules {
|
||||
resourceKey := rule.ResourceType + ":" + rule.ResourceID
|
||||
if !resourceKeys[resourceKey] {
|
||||
continue
|
||||
}
|
||||
subjectKey := rule.SubjectType + ":" + rule.SubjectID
|
||||
if rule.Effect == "deny" && subjects[subjectKey] {
|
||||
return false
|
||||
}
|
||||
if rule.Effect == "allow" {
|
||||
allowByResource[resourceKey] = true
|
||||
if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel {
|
||||
matchedAllowByResource[resourceKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for resourceKey := range allowByResource {
|
||||
if !matchedAllowByResource[resourceKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accessRuleSubjects(user *auth.User) map[string]bool {
|
||||
subjects := map[string]bool{}
|
||||
if user == nil {
|
||||
return subjects
|
||||
}
|
||||
add := func(subjectType string, id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
subjects[subjectType+":"+id] = true
|
||||
}
|
||||
}
|
||||
add("user", firstNonEmpty(user.GatewayUserID, user.ID))
|
||||
add("tenant", firstNonEmpty(user.GatewayTenantID, user.TenantID))
|
||||
add("api_key", user.APIKeyID)
|
||||
add("user_group", user.UserGroupID)
|
||||
for _, groupKey := range user.UserGroupKeys {
|
||||
add("user_group", groupKey)
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
const accessRuleColumns = `
|
||||
id::text, subject_type, subject_id::text, resource_type, resource_id::text, effect,
|
||||
priority, min_permission_level, conditions, metadata, status, created_at, updated_at`
|
||||
|
||||
func scanAccessRule(row scanner) (AccessRule, error) {
|
||||
var item AccessRule
|
||||
var conditions []byte
|
||||
var metadata []byte
|
||||
if err := row.Scan(
|
||||
&item.ID,
|
||||
&item.SubjectType,
|
||||
&item.SubjectID,
|
||||
&item.ResourceType,
|
||||
&item.ResourceID,
|
||||
&item.Effect,
|
||||
&item.Priority,
|
||||
&item.MinPermissionLevel,
|
||||
&conditions,
|
||||
&metadata,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return AccessRule{}, err
|
||||
}
|
||||
item.Conditions = decodeObject(conditions)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeAccessRuleInput(input AccessRuleInput) AccessRuleInput {
|
||||
input.SubjectType = strings.TrimSpace(input.SubjectType)
|
||||
input.SubjectID = strings.TrimSpace(input.SubjectID)
|
||||
input.ResourceType = strings.TrimSpace(input.ResourceType)
|
||||
input.ResourceID = strings.TrimSpace(input.ResourceID)
|
||||
input.Effect = firstNonEmpty(strings.TrimSpace(input.Effect), "allow")
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active")
|
||||
if input.Priority == 0 {
|
||||
input.Priority = 100
|
||||
}
|
||||
if input.MinPermissionLevel < 0 {
|
||||
input.MinPermissionLevel = 0
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeAccessRuleBatchInput(input AccessRuleBatchInput) AccessRuleBatchInput {
|
||||
input.SubjectType = strings.TrimSpace(input.SubjectType)
|
||||
input.SubjectID = strings.TrimSpace(input.SubjectID)
|
||||
input.Effect = firstNonEmpty(strings.TrimSpace(input.Effect), "allow")
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeAccessRuleResource(input AccessRuleResourceInput, effect string) AccessRuleResourceInput {
|
||||
input.ResourceType = strings.TrimSpace(input.ResourceType)
|
||||
input.ResourceID = strings.TrimSpace(input.ResourceID)
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active")
|
||||
if input.Priority == 0 {
|
||||
if effect == "deny" {
|
||||
input.Priority = 10
|
||||
} else {
|
||||
input.Priority = 100
|
||||
}
|
||||
}
|
||||
if input.MinPermissionLevel < 0 {
|
||||
input.MinPermissionLevel = 0
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func dedupeAccessRuleResources(resources []AccessRuleResourceInput) []AccessRuleResourceInput {
|
||||
seen := map[string]bool{}
|
||||
out := make([]AccessRuleResourceInput, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
resource.ResourceType = strings.TrimSpace(resource.ResourceType)
|
||||
resource.ResourceID = strings.TrimSpace(resource.ResourceID)
|
||||
key := resource.ResourceType + ":" + resource.ResourceID
|
||||
if resource.ResourceType == "" || resource.ResourceID == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, resource)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -10,19 +10,27 @@ import (
|
||||
|
||||
const baseModelColumns = `
|
||||
id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
|
||||
capabilities, base_billing_config, default_rate_limit_policy, metadata, pricing_version,
|
||||
status, created_at, updated_at`
|
||||
capabilities, base_billing_config, default_rate_limit_policy, COALESCE(pricing_rule_set_id::text, ''),
|
||||
COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, metadata,
|
||||
catalog_type, COALESCE(default_snapshot, '{}'::jsonb), COALESCE(customized_at::text, ''),
|
||||
pricing_version, status, created_at, updated_at`
|
||||
|
||||
type BaseModelInput struct {
|
||||
ProviderKey string `json:"providerKey"`
|
||||
CanonicalModelKey string `json:"canonicalModelKey"`
|
||||
ProviderModelName string `json:"providerModelName"`
|
||||
ModelType string `json:"modelType"`
|
||||
ModelType StringList `json:"modelType"`
|
||||
ModelAlias string `json:"modelAlias"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
BaseBillingConfig map[string]any `json:"baseBillingConfig"`
|
||||
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy"`
|
||||
PricingRuleSetID string `json:"pricingRuleSetId"`
|
||||
RuntimePolicySetID string `json:"runtimePolicySetId"`
|
||||
RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
CatalogType string `json:"catalogType"`
|
||||
DefaultSnapshot map[string]any `json:"defaultSnapshot"`
|
||||
PricingVersion int `json:"pricingVersion"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -31,6 +39,22 @@ type baseModelScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
type StringList []string
|
||||
|
||||
func (list *StringList) UnmarshalJSON(data []byte) error {
|
||||
var values []string
|
||||
if err := json.Unmarshal(data, &values); err == nil {
|
||||
*list = uniqueStringList(values)
|
||||
return nil
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*list = uniqueStringList([]string{value})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListBaseModels(ctx context.Context) ([]BaseModel, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+baseModelColumns+`
|
||||
@@ -57,27 +81,39 @@ func (s *Store) CreateBaseModel(ctx context.Context, input BaseModelInput) (Base
|
||||
capabilities, _ := json.Marshal(emptyObjectIfNil(input.Capabilities))
|
||||
billingConfig, _ := json.Marshal(emptyObjectIfNil(input.BaseBillingConfig))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.DefaultRateLimitPolicy))
|
||||
runtimePolicyOverride, _ := json.Marshal(emptyObjectIfNil(input.RuntimePolicyOverride))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot))
|
||||
modelType := primaryString(input.ModelType, "text_generate")
|
||||
|
||||
return scanBaseModel(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO base_model_catalog (
|
||||
provider_id, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
|
||||
capabilities, base_billing_config, default_rate_limit_policy, metadata, pricing_version, status
|
||||
capabilities, base_billing_config, default_rate_limit_policy, pricing_rule_set_id, runtime_policy_set_id, runtime_policy_override,
|
||||
metadata, catalog_type, default_snapshot, pricing_version, status
|
||||
)
|
||||
VALUES (
|
||||
(SELECT id FROM model_catalog_providers WHERE provider_key = $1 OR provider_code = $1 LIMIT 1),
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||
COALESCE(NULLIF($9, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)),
|
||||
COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)),
|
||||
$11, $12, NULLIF($13, ''), NULLIF($14::jsonb, '{}'::jsonb), $15, $16
|
||||
)
|
||||
RETURNING `+baseModelColumns,
|
||||
input.ProviderKey,
|
||||
input.CanonicalModelKey,
|
||||
input.ProviderModelName,
|
||||
input.ModelType,
|
||||
input.DisplayName,
|
||||
modelType,
|
||||
input.ModelAlias,
|
||||
capabilities,
|
||||
billingConfig,
|
||||
rateLimitPolicy,
|
||||
input.PricingRuleSetID,
|
||||
input.RuntimePolicySetID,
|
||||
runtimePolicyOverride,
|
||||
metadata,
|
||||
input.CatalogType,
|
||||
string(defaultSnapshot),
|
||||
input.PricingVersion,
|
||||
input.Status,
|
||||
))
|
||||
@@ -88,7 +124,10 @@ func (s *Store) UpdateBaseModel(ctx context.Context, id string, input BaseModelI
|
||||
capabilities, _ := json.Marshal(emptyObjectIfNil(input.Capabilities))
|
||||
billingConfig, _ := json.Marshal(emptyObjectIfNil(input.BaseBillingConfig))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.DefaultRateLimitPolicy))
|
||||
runtimePolicyOverride, _ := json.Marshal(emptyObjectIfNil(input.RuntimePolicyOverride))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot))
|
||||
modelType := primaryString(input.ModelType, "text_generate")
|
||||
|
||||
return scanBaseModel(s.pool.QueryRow(ctx, `
|
||||
UPDATE base_model_catalog
|
||||
@@ -101,9 +140,15 @@ SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = $
|
||||
capabilities = $7,
|
||||
base_billing_config = $8,
|
||||
default_rate_limit_policy = $9,
|
||||
metadata = $10,
|
||||
pricing_version = $11,
|
||||
status = $12,
|
||||
pricing_rule_set_id = COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)),
|
||||
runtime_policy_set_id = COALESCE(NULLIF($11, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)),
|
||||
runtime_policy_override = $12,
|
||||
metadata = $13,
|
||||
catalog_type = NULLIF($14, ''),
|
||||
default_snapshot = COALESCE(NULLIF($15::jsonb, '{}'::jsonb), default_snapshot),
|
||||
customized_at = CASE WHEN NULLIF($14, '') = 'system' THEN now() ELSE NULL END,
|
||||
pricing_version = $16,
|
||||
status = $17,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+baseModelColumns,
|
||||
@@ -111,17 +156,117 @@ RETURNING `+baseModelColumns,
|
||||
input.ProviderKey,
|
||||
input.CanonicalModelKey,
|
||||
input.ProviderModelName,
|
||||
input.ModelType,
|
||||
input.DisplayName,
|
||||
modelType,
|
||||
input.ModelAlias,
|
||||
capabilities,
|
||||
billingConfig,
|
||||
rateLimitPolicy,
|
||||
input.PricingRuleSetID,
|
||||
input.RuntimePolicySetID,
|
||||
runtimePolicyOverride,
|
||||
metadata,
|
||||
input.CatalogType,
|
||||
string(defaultSnapshot),
|
||||
input.PricingVersion,
|
||||
input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) ResetBaseModelToDefault(ctx context.Context, id string) (BaseModel, error) {
|
||||
var catalogType string
|
||||
var snapshotBytes []byte
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT catalog_type, COALESCE(default_snapshot, '{}'::jsonb)
|
||||
FROM base_model_catalog
|
||||
WHERE id = $1::uuid`, id).Scan(&catalogType, &snapshotBytes); err != nil {
|
||||
return BaseModel{}, err
|
||||
}
|
||||
if catalogType != "system" {
|
||||
return BaseModel{}, ErrProtectedDefault
|
||||
}
|
||||
snapshot := decodeObject(snapshotBytes)
|
||||
if len(snapshot) == 0 {
|
||||
return BaseModel{}, ErrProtectedDefault
|
||||
}
|
||||
|
||||
return scanBaseModel(s.pool.QueryRow(ctx, `
|
||||
UPDATE base_model_catalog
|
||||
SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = COALESCE($2::text, provider_key) OR provider_code = COALESCE($2::text, provider_key) LIMIT 1),
|
||||
provider_key = COALESCE($2::text, provider_key),
|
||||
canonical_model_key = COALESCE($3::text, canonical_model_key),
|
||||
provider_model_name = COALESCE($4::text, provider_model_name),
|
||||
model_type = COALESCE($5::text, model_type),
|
||||
display_name = COALESCE($6::text, display_name),
|
||||
capabilities = COALESCE($7::jsonb, capabilities),
|
||||
base_billing_config = COALESCE($8::jsonb, base_billing_config),
|
||||
default_rate_limit_policy = COALESCE($9::jsonb, default_rate_limit_policy),
|
||||
pricing_rule_set_id = COALESCE(NULLIF($10::text, '')::uuid, pricing_rule_set_id),
|
||||
runtime_policy_set_id = COALESCE(NULLIF($11::text, '')::uuid, runtime_policy_set_id),
|
||||
runtime_policy_override = COALESCE($12::jsonb, runtime_policy_override),
|
||||
metadata = COALESCE($13::jsonb, metadata),
|
||||
pricing_version = COALESCE($14::integer, pricing_version),
|
||||
status = COALESCE($15::text, status),
|
||||
customized_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+baseModelColumns,
|
||||
id,
|
||||
stringFromSnapshot(snapshot, "providerKey"),
|
||||
stringFromSnapshot(snapshot, "canonicalModelKey"),
|
||||
stringFromSnapshot(snapshot, "providerModelName"),
|
||||
stringFromSnapshot(snapshot, "modelType"),
|
||||
stringFromSnapshot(snapshot, "modelAlias", "displayName"),
|
||||
jsonFromSnapshot(snapshot, "capabilities"),
|
||||
jsonFromSnapshot(snapshot, "baseBillingConfig"),
|
||||
jsonFromSnapshot(snapshot, "defaultRateLimitPolicy"),
|
||||
stringFromSnapshot(snapshot, "pricingRuleSetId"),
|
||||
stringFromSnapshot(snapshot, "runtimePolicySetId"),
|
||||
jsonFromSnapshot(snapshot, "runtimePolicyOverride"),
|
||||
jsonFromSnapshot(snapshot, "metadata"),
|
||||
intFromSnapshot(snapshot, "pricingVersion"),
|
||||
stringFromSnapshot(snapshot, "status"),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) ResetAllBaseModelsToDefault(ctx context.Context) ([]BaseModel, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
UPDATE base_model_catalog
|
||||
SET provider_id = (
|
||||
SELECT id
|
||||
FROM model_catalog_providers
|
||||
WHERE provider_key = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key)
|
||||
OR provider_code = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key)
|
||||
LIMIT 1
|
||||
),
|
||||
provider_key = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key),
|
||||
canonical_model_key = COALESCE(NULLIF(default_snapshot->>'canonicalModelKey', ''), canonical_model_key),
|
||||
provider_model_name = COALESCE(NULLIF(default_snapshot->>'providerModelName', ''), provider_model_name),
|
||||
model_type = COALESCE(NULLIF(CASE
|
||||
WHEN jsonb_typeof(default_snapshot->'modelType') = 'array' THEN default_snapshot->'modelType'->>0
|
||||
ELSE default_snapshot->>'modelType'
|
||||
END, ''), model_type),
|
||||
display_name = COALESCE(NULLIF(COALESCE(default_snapshot->>'modelAlias', default_snapshot->>'displayName'), ''), display_name),
|
||||
capabilities = COALESCE(default_snapshot->'capabilities', capabilities),
|
||||
base_billing_config = COALESCE(default_snapshot->'baseBillingConfig', base_billing_config),
|
||||
default_rate_limit_policy = COALESCE(default_snapshot->'defaultRateLimitPolicy', default_rate_limit_policy),
|
||||
pricing_rule_set_id = COALESCE(NULLIF(default_snapshot->>'pricingRuleSetId', '')::uuid, pricing_rule_set_id),
|
||||
runtime_policy_set_id = COALESCE(NULLIF(default_snapshot->>'runtimePolicySetId', '')::uuid, runtime_policy_set_id),
|
||||
runtime_policy_override = COALESCE(default_snapshot->'runtimePolicyOverride', runtime_policy_override),
|
||||
metadata = COALESCE(default_snapshot->'metadata', metadata),
|
||||
pricing_version = COALESCE(NULLIF(default_snapshot->>'pricingVersion', '')::integer, pricing_version),
|
||||
status = COALESCE(NULLIF(default_snapshot->>'status', ''), status),
|
||||
customized_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE catalog_type = 'system'
|
||||
AND COALESCE(default_snapshot, '{}'::jsonb) <> '{}'::jsonb
|
||||
RETURNING `+baseModelColumns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanBaseModelRows(rows)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBaseModel(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM base_model_catalog WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
@@ -133,23 +278,45 @@ func (s *Store) DeleteBaseModel(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanBaseModelRows(rows pgx.Rows) ([]BaseModel, error) {
|
||||
items := make([]BaseModel, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanBaseModel(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func scanBaseModel(scanner baseModelScanner) (BaseModel, error) {
|
||||
var item BaseModel
|
||||
var modelType string
|
||||
var modelAlias string
|
||||
var capabilities []byte
|
||||
var billingConfig []byte
|
||||
var rateLimitPolicy []byte
|
||||
var runtimePolicyOverride []byte
|
||||
var metadata []byte
|
||||
var defaultSnapshot []byte
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.ProviderKey,
|
||||
&item.CanonicalModelKey,
|
||||
&item.ProviderModelName,
|
||||
&item.ModelType,
|
||||
&item.DisplayName,
|
||||
&modelType,
|
||||
&modelAlias,
|
||||
&capabilities,
|
||||
&billingConfig,
|
||||
&rateLimitPolicy,
|
||||
&item.PricingRuleSetID,
|
||||
&item.RuntimePolicySetID,
|
||||
&runtimePolicyOverride,
|
||||
&metadata,
|
||||
&item.CatalogType,
|
||||
&defaultSnapshot,
|
||||
&item.CustomizedAt,
|
||||
&item.PricingVersion,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
@@ -160,7 +327,12 @@ func scanBaseModel(scanner baseModelScanner) (BaseModel, error) {
|
||||
item.Capabilities = decodeObject(capabilities)
|
||||
item.BaseBillingConfig = decodeObject(billingConfig)
|
||||
item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
item.DefaultSnapshot = decodeObject(defaultSnapshot)
|
||||
item.ModelType = baseModelTypes(item.Capabilities, item.Metadata, modelType)
|
||||
item.ModelAlias = modelAlias
|
||||
item.DisplayName = modelAlias
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -168,17 +340,27 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput {
|
||||
input.ProviderKey = strings.TrimSpace(input.ProviderKey)
|
||||
input.CanonicalModelKey = strings.TrimSpace(input.CanonicalModelKey)
|
||||
input.ProviderModelName = strings.TrimSpace(input.ProviderModelName)
|
||||
input.ModelType = strings.TrimSpace(input.ModelType)
|
||||
input.ModelType = uniqueStringList(input.ModelType)
|
||||
input.ModelAlias = strings.TrimSpace(input.ModelAlias)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.PricingRuleSetID = strings.TrimSpace(input.PricingRuleSetID)
|
||||
input.RuntimePolicySetID = strings.TrimSpace(input.RuntimePolicySetID)
|
||||
input.CatalogType = strings.TrimSpace(input.CatalogType)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.CanonicalModelKey == "" && input.ProviderKey != "" && input.ProviderModelName != "" {
|
||||
input.CanonicalModelKey = input.ProviderKey + ":" + input.ProviderModelName
|
||||
}
|
||||
if input.DisplayName == "" {
|
||||
input.DisplayName = input.ProviderModelName
|
||||
if input.ModelAlias == "" {
|
||||
input.ModelAlias = input.DisplayName
|
||||
}
|
||||
if input.ModelType == "" {
|
||||
input.ModelType = "text_generate"
|
||||
if input.ModelAlias == "" {
|
||||
input.ModelAlias = input.ProviderModelName
|
||||
}
|
||||
if len(input.ModelType) == 0 {
|
||||
input.ModelType = StringList{"text_generate"}
|
||||
}
|
||||
if input.CatalogType == "" {
|
||||
input.CatalogType = "custom"
|
||||
}
|
||||
if input.PricingVersion <= 0 {
|
||||
input.PricingVersion = 1
|
||||
@@ -188,3 +370,102 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput {
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func stringFromSnapshot(snapshot map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
value, ok := snapshot[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return typed
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
if primary := primaryString(typed, ""); primary != "" {
|
||||
return primary
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intFromSnapshot(snapshot map[string]any, key string) any {
|
||||
switch value := snapshot[key].(type) {
|
||||
case float64:
|
||||
return int(value)
|
||||
case int:
|
||||
return value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func jsonFromSnapshot(snapshot map[string]any, key string) any {
|
||||
value, ok := snapshot[key]
|
||||
if !ok || value == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func baseModelTypes(capabilities map[string]any, metadata map[string]any, fallback string) StringList {
|
||||
values := make([]string, 0)
|
||||
values = append(values, stringListFromAny(capabilities["originalTypes"])...)
|
||||
values = append(values, stringListFromAny(metadata["originalTypes"])...)
|
||||
if fallback != "" {
|
||||
values = append(values, fallback)
|
||||
}
|
||||
return uniqueStringList(values)
|
||||
}
|
||||
|
||||
func stringListFromAny(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return typed
|
||||
case []any:
|
||||
values := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if text, ok := item.(string); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
return values
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStringList(values []string) StringList {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func primaryString(values []string, fallback string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string) ([]RuntimeModelCandidate, error) {
|
||||
func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string, user *auth.User) ([]RuntimeModelCandidate, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT p.id::text, p.platform_key, p.name, p.provider, COALESCE(p.base_url, ''),
|
||||
SELECT p.id::text, p.platform_key, p.name, p.provider,
|
||||
COALESCE(NULLIF(p.config->>'specType', ''), NULLIF(cp.provider_type, ''), NULLIF(p.config->>'sourceSpecType', ''), p.provider) AS spec_type,
|
||||
COALESCE(p.base_url, ''),
|
||||
p.auth_type, p.credentials, p.config, p.default_pricing_mode,
|
||||
p.default_discount_factor::float8, COALESCE(p.pricing_rule_set_id::text, ''),
|
||||
p.retry_policy, p.rate_limit_policy,
|
||||
@@ -17,16 +21,24 @@ SELECT p.id::text, p.platform_key, p.name, p.provider, COALESCE(p.base_url, ''),
|
||||
m.model_type, m.display_name, m.capabilities, m.capability_override,
|
||||
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
|
||||
m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''),
|
||||
m.retry_policy, m.rate_limit_policy
|
||||
m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, COALESCE(b.runtime_policy_set_id::text, '')),
|
||||
COALESCE(NULLIF(m.runtime_policy_override, '{}'::jsonb), b.runtime_policy_override, '{}'::jsonb)
|
||||
FROM platform_models m
|
||||
JOIN integration_platforms p ON p.id = m.platform_id
|
||||
LEFT JOIN model_catalog_providers cp ON cp.provider_key = p.provider OR cp.provider_code = p.provider
|
||||
LEFT JOIN base_model_catalog b ON b.id = m.base_model_id
|
||||
LEFT JOIN runtime_client_states s
|
||||
ON s.client_id = p.platform_key || ':' || m.model_type || ':' || m.model_name
|
||||
WHERE p.status = 'enabled'
|
||||
AND p.deleted_at IS NULL
|
||||
AND m.enabled = true
|
||||
AND m.model_type = $2
|
||||
AND (
|
||||
m.model_type = $2
|
||||
OR ($2 = 'text_generate' AND m.model_type IN ('chat', 'responses', 'text'))
|
||||
OR ($2 = 'image_generate' AND m.model_type IN ('image', 'images.generations'))
|
||||
OR ($2 = 'image_edit' AND m.model_type IN ('images.edits'))
|
||||
OR ($2 = 'video_generate' AND m.model_type IN ('video', 'videos.generations'))
|
||||
)
|
||||
AND (p.cooldown_until IS NULL OR p.cooldown_until <= now())
|
||||
AND (
|
||||
m.model_name = $1
|
||||
@@ -57,13 +69,16 @@ ORDER BY effective_priority ASC,
|
||||
var baseBilling []byte
|
||||
var billing []byte
|
||||
var billingOverride []byte
|
||||
var permissionConfig []byte
|
||||
var modelRetryPolicy []byte
|
||||
var modelRateLimitPolicy []byte
|
||||
var runtimePolicyOverride []byte
|
||||
if err := rows.Scan(
|
||||
&item.PlatformID,
|
||||
&item.PlatformKey,
|
||||
&item.PlatformName,
|
||||
&item.Provider,
|
||||
&item.SpecType,
|
||||
&item.BaseURL,
|
||||
&item.AuthType,
|
||||
&credentials,
|
||||
@@ -90,8 +105,11 @@ ORDER BY effective_priority ASC,
|
||||
&item.PricingMode,
|
||||
&item.DiscountFactor,
|
||||
&item.ModelPricingRuleSetID,
|
||||
&permissionConfig,
|
||||
&modelRetryPolicy,
|
||||
&modelRateLimitPolicy,
|
||||
&item.RuntimePolicySetID,
|
||||
&runtimePolicyOverride,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -104,8 +122,10 @@ ORDER BY effective_priority ASC,
|
||||
item.BaseBillingConfig = decodeObject(baseBilling)
|
||||
item.BillingConfig = decodeObject(billing)
|
||||
item.BillingConfigOverride = decodeObject(billingOverride)
|
||||
item.PermissionConfig = decodeObject(permissionConfig)
|
||||
item.ModelRetryPolicy = decodeObject(modelRetryPolicy)
|
||||
item.ModelRateLimitPolicy = decodeObject(modelRateLimitPolicy)
|
||||
item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride)
|
||||
item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, item.ModelName)
|
||||
item.QueueKey = item.ClientID
|
||||
items = append(items, item)
|
||||
@@ -116,5 +136,12 @@ ORDER BY effective_priority ASC,
|
||||
if len(items) == 0 {
|
||||
return nil, ErrNoModelCandidate
|
||||
}
|
||||
items, err = s.filterCandidatesByAccessRules(ctx, user, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, ErrNoModelCandidate
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
const catalogProviderColumns = `
|
||||
id::text, provider_key, COALESCE(NULLIF(provider_code, ''), provider_key) AS provider_code,
|
||||
display_name, provider_type, COALESCE(icon_path, '') AS icon_path,
|
||||
COALESCE(default_base_url, '') AS default_base_url, COALESCE(default_auth_type, '') AS default_auth_type,
|
||||
COALESCE(source, '') AS source, capability_schema, default_rate_limit_policy,
|
||||
metadata, status, created_at, updated_at`
|
||||
|
||||
@@ -20,6 +21,8 @@ type CatalogProviderInput struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
ProviderType string `json:"providerType"`
|
||||
IconPath string `json:"iconPath"`
|
||||
DefaultBaseURL string `json:"defaultBaseUrl"`
|
||||
DefaultAuthType string `json:"defaultAuthType"`
|
||||
Source string `json:"source"`
|
||||
CapabilitySchema map[string]any `json:"capabilitySchema"`
|
||||
DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy"`
|
||||
@@ -39,16 +42,18 @@ func (s *Store) CreateCatalogProvider(ctx context.Context, input CatalogProvider
|
||||
|
||||
return scanCatalogProvider(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO model_catalog_providers (
|
||||
provider_key, provider_code, display_name, provider_type, icon_path, source,
|
||||
provider_key, provider_code, display_name, provider_type, icon_path, default_base_url, default_auth_type, source,
|
||||
capability_schema, default_rate_limit_policy, metadata, status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8, $9, $10)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+catalogProviderColumns,
|
||||
input.ProviderKey,
|
||||
input.Code,
|
||||
input.DisplayName,
|
||||
input.ProviderType,
|
||||
input.IconPath,
|
||||
input.DefaultBaseURL,
|
||||
input.DefaultAuthType,
|
||||
input.Source,
|
||||
capabilitySchema,
|
||||
rateLimitPolicy,
|
||||
@@ -70,11 +75,13 @@ SET provider_key = $2,
|
||||
display_name = $4,
|
||||
provider_type = $5,
|
||||
icon_path = NULLIF($6, ''),
|
||||
source = $7,
|
||||
capability_schema = $8,
|
||||
default_rate_limit_policy = $9,
|
||||
metadata = $10,
|
||||
status = $11,
|
||||
default_base_url = NULLIF($7, ''),
|
||||
default_auth_type = $8,
|
||||
source = $9,
|
||||
capability_schema = $10,
|
||||
default_rate_limit_policy = $11,
|
||||
metadata = $12,
|
||||
status = $13,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+catalogProviderColumns,
|
||||
@@ -84,6 +91,8 @@ RETURNING `+catalogProviderColumns,
|
||||
input.DisplayName,
|
||||
input.ProviderType,
|
||||
input.IconPath,
|
||||
input.DefaultBaseURL,
|
||||
input.DefaultAuthType,
|
||||
input.Source,
|
||||
capabilitySchema,
|
||||
rateLimitPolicy,
|
||||
@@ -115,6 +124,8 @@ func scanCatalogProvider(scanner catalogProviderScanner) (CatalogProvider, error
|
||||
&item.DisplayName,
|
||||
&item.ProviderType,
|
||||
&item.IconPath,
|
||||
&item.DefaultBaseURL,
|
||||
&item.DefaultAuthType,
|
||||
&item.Source,
|
||||
&capabilitySchema,
|
||||
&rateLimitPolicy,
|
||||
@@ -137,6 +148,8 @@ func normalizeCatalogProviderInput(input CatalogProviderInput) CatalogProviderIn
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.ProviderType = strings.TrimSpace(input.ProviderType)
|
||||
input.IconPath = strings.TrimSpace(input.IconPath)
|
||||
input.DefaultBaseURL = strings.TrimSpace(input.DefaultBaseURL)
|
||||
input.DefaultAuthType = strings.TrimSpace(input.DefaultAuthType)
|
||||
input.Source = strings.TrimSpace(input.Source)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Code == "" {
|
||||
@@ -145,6 +158,9 @@ func normalizeCatalogProviderInput(input CatalogProviderInput) CatalogProviderIn
|
||||
if input.ProviderType == "" {
|
||||
input.ProviderType = "openai"
|
||||
}
|
||||
if input.DefaultAuthType == "" {
|
||||
input.DefaultAuthType = "APIKey"
|
||||
}
|
||||
if input.Source == "" {
|
||||
input.Source = "gateway"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type GatewayTenantInput struct {
|
||||
TenantKey string `json:"tenantKey"`
|
||||
Source string `json:"source"`
|
||||
ExternalTenantID string `json:"externalTenantId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultUserGroupID string `json:"defaultUserGroupId"`
|
||||
PlanKey string `json:"planKey"`
|
||||
BillingProfile map[string]any `json:"billingProfile"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
||||
AuthPolicy map[string]any `json:"authPolicy"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GatewayUserInput struct {
|
||||
UserKey string `json:"userKey"`
|
||||
Source string `json:"source"`
|
||||
ExternalUserID string `json:"externalUserId"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Password string `json:"password"`
|
||||
GatewayTenantID string `json:"gatewayTenantId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantKey string `json:"tenantKey"`
|
||||
DefaultUserGroupID string `json:"defaultUserGroupId"`
|
||||
Roles []string `json:"roles"`
|
||||
AuthProfile map[string]any `json:"authProfile"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UserGroupInput struct {
|
||||
GroupKey string `json:"groupKey"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Priority int `json:"priority"`
|
||||
RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy"`
|
||||
BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
||||
QuotaPolicy map[string]any `json:"quotaPolicy"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateTenant(ctx context.Context, input GatewayTenantInput) (GatewayTenant, error) {
|
||||
input = normalizeTenantInput(input)
|
||||
billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanTenant(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_tenants (
|
||||
tenant_key, source, external_tenant_id, name, description, default_user_group_id, plan_key,
|
||||
billing_profile, rate_limit_policy, auth_policy, metadata, status
|
||||
)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, '')::uuid, NULLIF($7, ''), $8, $9, $10, $11, $12)
|
||||
RETURNING `+tenantColumns,
|
||||
input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID, input.PlanKey,
|
||||
billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTenant(ctx context.Context, id string, input GatewayTenantInput) (GatewayTenant, error) {
|
||||
input = normalizeTenantInput(input)
|
||||
billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanTenant(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_tenants
|
||||
SET tenant_key = $2,
|
||||
source = $3,
|
||||
external_tenant_id = NULLIF($4, ''),
|
||||
name = $5,
|
||||
description = NULLIF($6, ''),
|
||||
default_user_group_id = NULLIF($7, '')::uuid,
|
||||
plan_key = NULLIF($8, ''),
|
||||
billing_profile = $9,
|
||||
rate_limit_policy = $10,
|
||||
auth_policy = $11,
|
||||
metadata = $12,
|
||||
status = $13,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND deleted_at IS NULL
|
||||
RETURNING `+tenantColumns,
|
||||
id, input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID,
|
||||
input.PlanKey, billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTenant(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_tenants
|
||||
SET deleted_at = now(),
|
||||
status = 'deleted',
|
||||
tenant_key = tenant_key || ':deleted:' || left(id::text, 8),
|
||||
external_tenant_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateGatewayUser(ctx context.Context, input GatewayUserInput) (GatewayUser, error) {
|
||||
input = normalizeUserInput(input)
|
||||
passwordHash := ""
|
||||
if strings.TrimSpace(input.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
roles, _ := json.Marshal(input.Roles)
|
||||
authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_users (
|
||||
user_key, source, external_user_id, username, display_name, email, phone, avatar_url, password_hash,
|
||||
gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, auth_profile, metadata, status
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''),
|
||||
NULLIF($10, '')::uuid, NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, $14, $15, $16, $17
|
||||
)
|
||||
RETURNING `+userColumns,
|
||||
input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone, input.AvatarURL, passwordHash,
|
||||
input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID, roles, authProfile, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateGatewayUser(ctx context.Context, id string, input GatewayUserInput) (GatewayUser, error) {
|
||||
input = normalizeUserInput(input)
|
||||
passwordHash := ""
|
||||
if strings.TrimSpace(input.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
roles, _ := json.Marshal(input.Roles)
|
||||
authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanUser(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_users
|
||||
SET user_key = $2,
|
||||
source = $3,
|
||||
external_user_id = NULLIF($4, ''),
|
||||
username = $5,
|
||||
display_name = NULLIF($6, ''),
|
||||
email = NULLIF($7, ''),
|
||||
phone = NULLIF($8, ''),
|
||||
avatar_url = NULLIF($9, ''),
|
||||
password_hash = COALESCE(NULLIF($10, ''), password_hash),
|
||||
gateway_tenant_id = NULLIF($11, '')::uuid,
|
||||
tenant_id = NULLIF($12, ''),
|
||||
tenant_key = NULLIF($13, ''),
|
||||
default_user_group_id = NULLIF($14, '')::uuid,
|
||||
roles = $15,
|
||||
auth_profile = $16,
|
||||
metadata = $17,
|
||||
status = $18,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND deleted_at IS NULL
|
||||
RETURNING `+userColumns,
|
||||
id, input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone,
|
||||
input.AvatarURL, passwordHash, input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID,
|
||||
roles, authProfile, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGatewayUser(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE gateway_users
|
||||
SET deleted_at = now(),
|
||||
status = 'deleted',
|
||||
user_key = user_key || ':deleted:' || left(id::text, 8),
|
||||
external_user_id = NULL,
|
||||
email = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserGroup(ctx context.Context, input UserGroupInput) (UserGroup, error) {
|
||||
input = normalizeUserGroupInput(input)
|
||||
rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy))
|
||||
billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanUserGroup(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO gateway_user_groups (
|
||||
group_key, name, description, source, priority, recharge_discount_policy, billing_discount_policy,
|
||||
rate_limit_policy, quota_policy, metadata, status
|
||||
)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING `+userGroupColumns,
|
||||
input.GroupKey, input.Name, input.Description, input.Source, input.Priority, rechargeDiscountPolicy, billingDiscountPolicy,
|
||||
rateLimitPolicy, quotaPolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserGroup(ctx context.Context, id string, input UserGroupInput) (UserGroup, error) {
|
||||
input = normalizeUserGroupInput(input)
|
||||
rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy))
|
||||
billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy))
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanUserGroup(s.pool.QueryRow(ctx, `
|
||||
UPDATE gateway_user_groups
|
||||
SET group_key = $2,
|
||||
name = $3,
|
||||
description = NULLIF($4, ''),
|
||||
source = $5,
|
||||
priority = $6,
|
||||
recharge_discount_policy = $7,
|
||||
billing_discount_policy = $8,
|
||||
rate_limit_policy = $9,
|
||||
quota_policy = $10,
|
||||
metadata = $11,
|
||||
status = $12,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+userGroupColumns,
|
||||
id, input.GroupKey, input.Name, input.Description, input.Source, input.Priority,
|
||||
rechargeDiscountPolicy, billingDiscountPolicy, rateLimitPolicy, quotaPolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserGroup(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const tenantColumns = `
|
||||
id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''),
|
||||
COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy,
|
||||
auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||
created_at, updated_at`
|
||||
|
||||
const userColumns = `
|
||||
id::text, user_key, source, COALESCE(external_user_id, ''), username,
|
||||
COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''),
|
||||
COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''),
|
||||
COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata,
|
||||
status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''),
|
||||
created_at, updated_at`
|
||||
|
||||
const userGroupColumns = `
|
||||
id::text, group_key, name, COALESCE(description, ''), source, priority,
|
||||
recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata,
|
||||
status, created_at, updated_at`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanTenant(row scanner) (GatewayTenant, error) {
|
||||
var item GatewayTenant
|
||||
var billingProfile []byte
|
||||
var rateLimitPolicy []byte
|
||||
var authPolicy []byte
|
||||
var metadata []byte
|
||||
if err := row.Scan(
|
||||
&item.ID, &item.TenantKey, &item.Source, &item.ExternalTenantID, &item.Name, &item.Description,
|
||||
&item.DefaultUserGroupID, &item.PlanKey, &billingProfile, &rateLimitPolicy, &authPolicy, &metadata,
|
||||
&item.Status, &item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt,
|
||||
); err != nil {
|
||||
return GatewayTenant{}, err
|
||||
}
|
||||
item.BillingProfile = decodeObject(billingProfile)
|
||||
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.AuthPolicy = decodeObject(authPolicy)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanUser(row scanner) (GatewayUser, error) {
|
||||
var item GatewayUser
|
||||
var roles []byte
|
||||
var authProfile []byte
|
||||
var metadata []byte
|
||||
if err := row.Scan(
|
||||
&item.ID, &item.UserKey, &item.Source, &item.ExternalUserID, &item.Username, &item.DisplayName,
|
||||
&item.Email, &item.Phone, &item.AvatarURL, &item.GatewayTenantID, &item.TenantID, &item.TenantKey,
|
||||
&item.DefaultUserGroupID, &roles, &authProfile, &metadata, &item.Status, &item.LastLoginAt,
|
||||
&item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt,
|
||||
); err != nil {
|
||||
return GatewayUser{}, err
|
||||
}
|
||||
item.Roles = decodeStringArray(roles)
|
||||
item.AuthProfile = decodeObject(authProfile)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanUserGroup(row scanner) (UserGroup, error) {
|
||||
var item UserGroup
|
||||
var rechargeDiscountPolicy []byte
|
||||
var billingDiscountPolicy []byte
|
||||
var rateLimitPolicy []byte
|
||||
var quotaPolicy []byte
|
||||
var metadata []byte
|
||||
if err := row.Scan(
|
||||
&item.ID, &item.GroupKey, &item.Name, &item.Description, &item.Source, &item.Priority,
|
||||
&rechargeDiscountPolicy, &billingDiscountPolicy, &rateLimitPolicy, "aPolicy, &metadata,
|
||||
&item.Status, &item.CreatedAt, &item.UpdatedAt,
|
||||
); err != nil {
|
||||
return UserGroup{}, err
|
||||
}
|
||||
item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy)
|
||||
item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy)
|
||||
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.QuotaPolicy = decodeObject(quotaPolicy)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeTenantInput(input GatewayTenantInput) GatewayTenantInput {
|
||||
input.TenantKey = strings.TrimSpace(input.TenantKey)
|
||||
input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway")
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active")
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeUserInput(input GatewayUserInput) GatewayUserInput {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway")
|
||||
input.UserKey = firstNonEmpty(strings.TrimSpace(input.UserKey), input.Source+":"+input.Username)
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active")
|
||||
input.Roles = normalizeGatewayRoles(input.Roles)
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeGatewayRoles(roles []string) []string {
|
||||
for _, role := range roles {
|
||||
switch strings.TrimSpace(role) {
|
||||
case "manager":
|
||||
return []string{"manager"}
|
||||
case "admin":
|
||||
return []string{"admin"}
|
||||
case "operator":
|
||||
return []string{"operator"}
|
||||
case "creator":
|
||||
return []string{"creator"}
|
||||
case "user":
|
||||
return []string{"user"}
|
||||
}
|
||||
}
|
||||
return []string{"user"}
|
||||
}
|
||||
|
||||
func normalizeUserGroupInput(input UserGroupInput) UserGroupInput {
|
||||
input.GroupKey = strings.TrimSpace(input.GroupKey)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway")
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active")
|
||||
if input.Priority == 0 {
|
||||
input.Priority = 100
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type platformModelQuerier interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
type modelCatalogSnapshot struct {
|
||||
ID string
|
||||
ProviderKey string
|
||||
@@ -18,10 +22,50 @@ type modelCatalogSnapshot struct {
|
||||
Capabilities map[string]any
|
||||
BaseBillingConfig map[string]any
|
||||
DefaultRateLimitPolicy map[string]any
|
||||
RuntimePolicySetID string
|
||||
RuntimePolicyOverride map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformModelInput) (PlatformModel, error) {
|
||||
base, err := s.lookupBaseModel(ctx, input.BaseModelID, input.CanonicalModelKey, input.ModelName)
|
||||
return s.createPlatformModel(ctx, s.pool, input)
|
||||
}
|
||||
|
||||
func (s *Store) ReplacePlatformModels(ctx context.Context, platformID string, inputs []CreatePlatformModelInput) ([]PlatformModel, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
keptIDs := make([]string, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
input.PlatformID = platformID
|
||||
model, err := s.createPlatformModel(ctx, tx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keptIDs = append(keptIDs, model.ID)
|
||||
}
|
||||
|
||||
if len(keptIDs) == 0 {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE platform_id = $1::uuid`, platformID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM platform_models
|
||||
WHERE platform_id = $1::uuid
|
||||
AND NOT (id::text = ANY($2::text[]))`, platformID, keptIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListPlatformModels(ctx, platformID)
|
||||
}
|
||||
|
||||
func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput) (PlatformModel, error) {
|
||||
base, err := s.lookupBaseModel(ctx, q, input.BaseModelID, input.CanonicalModelKey, input.ModelName)
|
||||
if err != nil && !IsNotFound(err) {
|
||||
return PlatformModel{}, err
|
||||
}
|
||||
@@ -34,6 +78,7 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod
|
||||
if input.DisplayName == "" {
|
||||
input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName)
|
||||
}
|
||||
input.ModelAlias = normalizePlatformModelAlias(input.ModelAlias, base)
|
||||
if input.PricingMode == "" {
|
||||
input.PricingMode = "inherit_discount"
|
||||
}
|
||||
@@ -49,6 +94,14 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod
|
||||
if len(rateLimitPolicy) == 0 {
|
||||
rateLimitPolicy = base.DefaultRateLimitPolicy
|
||||
}
|
||||
runtimePolicySetID := strings.TrimSpace(input.RuntimePolicySetID)
|
||||
if runtimePolicySetID == "" {
|
||||
runtimePolicySetID = base.RuntimePolicySetID
|
||||
}
|
||||
runtimePolicyOverride := input.RuntimePolicyOverride
|
||||
if len(runtimePolicyOverride) == 0 {
|
||||
runtimePolicyOverride = base.RuntimePolicyOverride
|
||||
}
|
||||
|
||||
capabilityOverrideJSON, _ := json.Marshal(emptyObjectIfNil(input.CapabilityOverride))
|
||||
capabilitiesJSON, _ := json.Marshal(emptyObjectIfNil(capabilities))
|
||||
@@ -57,6 +110,7 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod
|
||||
permissionJSON, _ := json.Marshal(emptyObjectIfNil(input.PermissionConfig))
|
||||
retryJSON, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy))
|
||||
rateLimitJSON, _ := json.Marshal(emptyObjectIfNil(rateLimitPolicy))
|
||||
runtimePolicyOverrideJSON, _ := json.Marshal(emptyObjectIfNil(runtimePolicyOverride))
|
||||
|
||||
discount := any(nil)
|
||||
if input.DiscountFactor > 0 {
|
||||
@@ -72,16 +126,22 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod
|
||||
var capabilitiesBytes []byte
|
||||
var billingOverrideBytes []byte
|
||||
var billingBytes []byte
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
var permissionBytes []byte
|
||||
var retryPolicyBytes []byte
|
||||
var rateLimitPolicyBytes []byte
|
||||
var runtimePolicyOverrideBytes []byte
|
||||
err = q.QueryRow(ctx, `
|
||||
INSERT INTO platform_models (
|
||||
platform_id, base_model_id, model_name, model_alias, model_type, display_name,
|
||||
capability_override, capabilities, pricing_mode, discount_factor,
|
||||
pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy, enabled
|
||||
pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy,
|
||||
runtime_policy_set_id, runtime_policy_override, enabled
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3, NULLIF($4, ''), $5, $6,
|
||||
$7::jsonb, $8::jsonb, $9, $10::numeric,
|
||||
NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, true
|
||||
NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb,
|
||||
NULLIF($17, '')::uuid, $18::jsonb, true
|
||||
)
|
||||
ON CONFLICT (platform_id, model_name, model_type) DO UPDATE
|
||||
SET base_model_id = EXCLUDED.base_model_id,
|
||||
@@ -97,12 +157,16 @@ SET base_model_id = EXCLUDED.base_model_id,
|
||||
permission_config = EXCLUDED.permission_config,
|
||||
retry_policy = EXCLUDED.retry_policy,
|
||||
rate_limit_policy = EXCLUDED.rate_limit_policy,
|
||||
runtime_policy_set_id = EXCLUDED.runtime_policy_set_id,
|
||||
runtime_policy_override = EXCLUDED.runtime_policy_override,
|
||||
enabled = true,
|
||||
updated_at = now()
|
||||
RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_name,
|
||||
COALESCE(model_alias, ''), model_type, display_name, capability_override,
|
||||
capabilities, pricing_mode, COALESCE(discount_factor, 0)::float8,
|
||||
COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config, enabled, created_at, updated_at`,
|
||||
COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config,
|
||||
permission_config, retry_policy, rate_limit_policy, COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override,
|
||||
enabled, created_at, updated_at`,
|
||||
input.PlatformID,
|
||||
baseID,
|
||||
input.ModelName,
|
||||
@@ -119,6 +183,8 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
||||
string(permissionJSON),
|
||||
string(retryJSON),
|
||||
string(rateLimitJSON),
|
||||
runtimePolicySetID,
|
||||
string(runtimePolicyOverrideJSON),
|
||||
).Scan(
|
||||
&model.ID,
|
||||
&model.PlatformID,
|
||||
@@ -134,6 +200,11 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
||||
&model.PricingRuleSetID,
|
||||
&billingOverrideBytes,
|
||||
&billingBytes,
|
||||
&permissionBytes,
|
||||
&retryPolicyBytes,
|
||||
&rateLimitPolicyBytes,
|
||||
&model.RuntimePolicySetID,
|
||||
&runtimePolicyOverrideBytes,
|
||||
&model.Enabled,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
@@ -145,17 +216,34 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
||||
model.Capabilities = decodeObject(capabilitiesBytes)
|
||||
model.BillingConfigOverride = decodeObject(billingOverrideBytes)
|
||||
model.BillingConfig = decodeObject(billingBytes)
|
||||
model.PermissionConfig = decodeObject(permissionBytes)
|
||||
model.RetryPolicy = decodeObject(retryPolicyBytes)
|
||||
model.RateLimitPolicy = decodeObject(rateLimitPolicyBytes)
|
||||
model.RuntimePolicyOverride = decodeObject(runtimePolicyOverrideBytes)
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func (s *Store) lookupBaseModel(ctx context.Context, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) {
|
||||
func (s *Store) DeletePlatformModel(ctx context.Context, id string) error {
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) {
|
||||
var item modelCatalogSnapshot
|
||||
var capabilities []byte
|
||||
var billingConfig []byte
|
||||
var rateLimitPolicy []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
var runtimePolicyOverride []byte
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name,
|
||||
capabilities, base_billing_config, default_rate_limit_policy
|
||||
capabilities, base_billing_config, default_rate_limit_policy,
|
||||
COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override
|
||||
FROM base_model_catalog
|
||||
WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid)
|
||||
OR ($2 <> '' AND canonical_model_key = $2)
|
||||
@@ -171,6 +259,8 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp
|
||||
&capabilities,
|
||||
&billingConfig,
|
||||
&rateLimitPolicy,
|
||||
&item.RuntimePolicySetID,
|
||||
&runtimePolicyOverride,
|
||||
)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
@@ -181,9 +271,31 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp
|
||||
item.Capabilities = decodeObject(capabilities)
|
||||
item.BaseBillingConfig = decodeObject(billingConfig)
|
||||
item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizePlatformModelAlias(alias string, base modelCatalogSnapshot) string {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
alias = firstNonEmpty(base.ProviderModelName, base.DisplayName, base.CanonicalModelKey)
|
||||
}
|
||||
if base.ProviderKey != "" {
|
||||
alias = strings.TrimPrefix(alias, base.ProviderKey+":")
|
||||
}
|
||||
if alias == base.CanonicalModelKey {
|
||||
alias = stripAliasPrefix(alias)
|
||||
}
|
||||
return strings.TrimSpace(alias)
|
||||
}
|
||||
|
||||
func stripAliasPrefix(alias string) string {
|
||||
if before, after, ok := strings.Cut(alias, ":"); ok && before != "" && after != "" {
|
||||
return after
|
||||
}
|
||||
return alias
|
||||
}
|
||||
|
||||
func mergeObjects(base map[string]any, override map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range base {
|
||||
|
||||
@@ -159,6 +159,13 @@ RETURNING `+pricingRuleSetColumns,
|
||||
}
|
||||
|
||||
func (s *Store) DeletePricingRuleSet(ctx context.Context, id string) error {
|
||||
var ruleSetKey string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT rule_set_key FROM model_pricing_rule_sets WHERE id = $1::uuid`, id).Scan(&ruleSetKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if ruleSetKey == "default-multimodal-v1" {
|
||||
return ErrProtectedDefault
|
||||
}
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM model_pricing_rule_sets WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const runtimePolicyColumns = `
|
||||
id::text, policy_key, name, COALESCE(description, ''), rate_limit_policy, retry_policy,
|
||||
auto_disable_policy, degrade_policy, metadata, status, created_at, updated_at`
|
||||
|
||||
type RuntimePolicySetInput struct {
|
||||
PolicyKey string `json:"policyKey"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RateLimitPolicy map[string]any `json:"rateLimitPolicy"`
|
||||
RetryPolicy map[string]any `json:"retryPolicy"`
|
||||
AutoDisablePolicy map[string]any `json:"autoDisablePolicy"`
|
||||
DegradePolicy map[string]any `json:"degradePolicy"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type runtimePolicyScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func (s *Store) ListRuntimePolicySets(ctx context.Context) ([]RuntimePolicySet, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+runtimePolicyColumns+` FROM model_runtime_policy_sets ORDER BY policy_key ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]RuntimePolicySet, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanRuntimePolicySet(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateRuntimePolicySet(ctx context.Context, input RuntimePolicySetInput) (RuntimePolicySet, error) {
|
||||
input = normalizeRuntimePolicyInput(input)
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy))
|
||||
autoDisablePolicy, _ := json.Marshal(emptyObjectIfNil(input.AutoDisablePolicy))
|
||||
degradePolicy, _ := json.Marshal(emptyObjectIfNil(input.DegradePolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanRuntimePolicySet(s.pool.QueryRow(ctx, `
|
||||
INSERT INTO model_runtime_policy_sets (
|
||||
policy_key, name, description, rate_limit_policy, retry_policy, auto_disable_policy, degrade_policy, metadata, status
|
||||
)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9)
|
||||
RETURNING `+runtimePolicyColumns,
|
||||
input.PolicyKey, input.Name, input.Description, rateLimitPolicy, retryPolicy,
|
||||
autoDisablePolicy, degradePolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) UpdateRuntimePolicySet(ctx context.Context, id string, input RuntimePolicySetInput) (RuntimePolicySet, error) {
|
||||
input = normalizeRuntimePolicyInput(input)
|
||||
rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy))
|
||||
retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy))
|
||||
autoDisablePolicy, _ := json.Marshal(emptyObjectIfNil(input.AutoDisablePolicy))
|
||||
degradePolicy, _ := json.Marshal(emptyObjectIfNil(input.DegradePolicy))
|
||||
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
|
||||
return scanRuntimePolicySet(s.pool.QueryRow(ctx, `
|
||||
UPDATE model_runtime_policy_sets
|
||||
SET policy_key = $2,
|
||||
name = $3,
|
||||
description = NULLIF($4, ''),
|
||||
rate_limit_policy = $5,
|
||||
retry_policy = $6,
|
||||
auto_disable_policy = $7,
|
||||
degrade_policy = $8,
|
||||
metadata = $9,
|
||||
status = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING `+runtimePolicyColumns,
|
||||
id, input.PolicyKey, input.Name, input.Description, rateLimitPolicy, retryPolicy,
|
||||
autoDisablePolicy, degradePolicy, metadata, input.Status,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRuntimePolicySet(ctx context.Context, id string) error {
|
||||
var policyKey string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT policy_key FROM model_runtime_policy_sets WHERE id = $1::uuid`, id).Scan(&policyKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if policyKey == "default-runtime-v1" {
|
||||
return ErrProtectedDefault
|
||||
}
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM model_runtime_policy_sets WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanRuntimePolicySet(scanner runtimePolicyScanner) (RuntimePolicySet, error) {
|
||||
var item RuntimePolicySet
|
||||
var rateLimitPolicy []byte
|
||||
var retryPolicy []byte
|
||||
var autoDisablePolicy []byte
|
||||
var degradePolicy []byte
|
||||
var metadata []byte
|
||||
if err := scanner.Scan(
|
||||
&item.ID,
|
||||
&item.PolicyKey,
|
||||
&item.Name,
|
||||
&item.Description,
|
||||
&rateLimitPolicy,
|
||||
&retryPolicy,
|
||||
&autoDisablePolicy,
|
||||
°radePolicy,
|
||||
&metadata,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
); err != nil {
|
||||
return RuntimePolicySet{}, err
|
||||
}
|
||||
item.RateLimitPolicy = decodeObject(rateLimitPolicy)
|
||||
item.RetryPolicy = decodeObject(retryPolicy)
|
||||
item.AutoDisablePolicy = decodeObject(autoDisablePolicy)
|
||||
item.DegradePolicy = decodeObject(degradePolicy)
|
||||
item.Metadata = decodeObject(metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeRuntimePolicyInput(input RuntimePolicySetInput) RuntimePolicySetInput {
|
||||
input.PolicyKey = strings.TrimSpace(input.PolicyKey)
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Description = strings.TrimSpace(input.Description)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Status == "" {
|
||||
input.Status = "active"
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS model_catalog_providers (
|
||||
display_name text NOT NULL,
|
||||
provider_type text NOT NULL DEFAULT 'openai',
|
||||
icon_path text,
|
||||
default_base_url text,
|
||||
default_auth_type text NOT NULL DEFAULT 'APIKey',
|
||||
source text NOT NULL DEFAULT 'gateway',
|
||||
capability_schema jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
default_rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
@@ -272,6 +274,7 @@ CREATE TABLE IF NOT EXISTS gateway_api_keys (
|
||||
tenant_key text,
|
||||
user_id text,
|
||||
key_prefix text NOT NULL,
|
||||
key_secret text,
|
||||
key_hash text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
scopes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
@@ -252,6 +252,7 @@ CREATE TABLE IF NOT EXISTS gateway_api_keys (
|
||||
tenant_key text,
|
||||
user_id text,
|
||||
key_prefix text NOT NULL,
|
||||
key_secret text,
|
||||
key_hash text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
scopes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
ALTER TABLE model_catalog_providers
|
||||
ADD COLUMN IF NOT EXISTS default_base_url text,
|
||||
ADD COLUMN IF NOT EXISTS default_auth_type text NOT NULL DEFAULT 'APIKey';
|
||||
|
||||
WITH source_defaults(provider_key, default_base_url, default_auth_type) AS (
|
||||
VALUES
|
||||
('easyai', 'https://51easyai.com/api/v1', 'APIKey'),
|
||||
('runninghub', 'https://www.runninghub.ai', 'APIKey'),
|
||||
('LiblibAI', 'https://openapi.liblibai.cloud', 'AccessKey-SecretKey'),
|
||||
('keling', 'https://api-beijing.klingai.com/v1', 'AccessKey-SecretKey'),
|
||||
('gemini', 'https://generativelanguage.googleapis.com/v1beta', 'APIKey'),
|
||||
('openai', 'https://api.openai.com/v1', 'APIKey'),
|
||||
('aliyun-bailian-openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'APIKey'),
|
||||
('gemini-openai', 'https://generativelanguage.googleapis.com/v1beta/openai', 'APIKey'),
|
||||
('volces-openai', 'https://ark.cn-beijing.volces.com/api/v3', 'APIKey'),
|
||||
('zhipu-openai', 'https://open.bigmodel.cn/api/paas/v4', 'APIKey'),
|
||||
('minimax-openai', 'https://api.minimaxi.com/v1', 'APIKey'),
|
||||
('openrouter-openai', 'https://openrouter.ai/api/v1', 'APIKey'),
|
||||
('aliyun-bailian', 'https://dashscope.aliyuncs.com/api/v1', 'APIKey'),
|
||||
('ollama', 'http://<your-local-ip>:11434/v1', 'APIKey'),
|
||||
('blackforest', 'https://api.bfl.ai/v1', 'APIKey'),
|
||||
('dify', 'http://localhost/v1', 'APIKey'),
|
||||
('volces', 'https://ark.cn-beijing.volces.com/api/v3', 'AccessKey-SecretKey'),
|
||||
('jimeng', '', 'APIKey'),
|
||||
('silicon-flow-openai', 'https://api.siliconflow.cn/v1', 'APIKey'),
|
||||
('tripo3d', 'https://api.tripo3d.ai/v2/openapi', 'APIKey'),
|
||||
('tencent-hunyuan-image', 'https://aiart.tencentcloudapi.com', 'AccessKey-SecretKey'),
|
||||
('tencent-hunyuan-video', 'https://vclm.tencentcloudapi.com', 'AccessKey-SecretKey'),
|
||||
('tencent-hunyuan', 'https://ai3d.tencentcloudapi.com', 'AccessKey-SecretKey'),
|
||||
('suno', 'https://api.cqtai.com/api/cqt', 'APIKey'),
|
||||
('minimax', 'https://api.minimaxi.com/v1', 'APIKey'),
|
||||
('midjourney', 'https://api.legnext.ai/api/v1', 'APIKey'),
|
||||
('tencent-lke', 'https://wss.lke.cloud.tencent.com/v1', 'AccessKey-SecretKey'),
|
||||
('universal', 'https://example.com/v1/image/edits', 'APIKey'),
|
||||
('newapi', 'https://api.newapi.com/v1', 'APIKey'),
|
||||
('vidu', 'https://api.vidu.cn/ent/v2', 'APIKey'),
|
||||
('n8n', 'http://127.0.0.1:5678', 'APIKey'),
|
||||
('mock-test', 'http://mock.test', 'APIKey')
|
||||
)
|
||||
UPDATE model_catalog_providers p
|
||||
SET default_base_url = NULLIF(source_defaults.default_base_url, ''),
|
||||
default_auth_type = source_defaults.default_auth_type,
|
||||
metadata = p.metadata || jsonb_build_object(
|
||||
'defaultConnectionSource', 'server-main.integration-platform',
|
||||
'defaultBaseUrlSynced', NULLIF(source_defaults.default_base_url, ''),
|
||||
'defaultAuthTypeSynced', source_defaults.default_auth_type
|
||||
),
|
||||
updated_at = now()
|
||||
FROM source_defaults
|
||||
WHERE p.provider_key = source_defaults.provider_key;
|
||||
@@ -0,0 +1,28 @@
|
||||
ALTER TABLE IF EXISTS base_model_catalog
|
||||
ADD COLUMN IF NOT EXISTS pricing_rule_set_id uuid REFERENCES model_pricing_rule_sets(id) ON DELETE SET NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
||||
WHERE c.contype = 'f'
|
||||
AND c.conrelid = 'base_model_catalog'::regclass
|
||||
AND a.attname = 'pricing_rule_set_id'
|
||||
) THEN
|
||||
ALTER TABLE base_model_catalog
|
||||
ADD CONSTRAINT fk_base_model_catalog_pricing_rule_set
|
||||
FOREIGN KEY (pricing_rule_set_id) REFERENCES model_pricing_rule_sets(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_base_model_catalog_pricing_rule_set
|
||||
ON base_model_catalog(pricing_rule_set_id);
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET pricing_rule_set_id = (
|
||||
SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1
|
||||
)
|
||||
WHERE pricing_rule_set_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1');
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE IF EXISTS integration_platforms
|
||||
ADD COLUMN IF NOT EXISTS internal_name text;
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE IF NOT EXISTS model_runtime_policy_sets (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
policy_key text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
auto_disable_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
degrade_policy jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO model_runtime_policy_sets (
|
||||
policy_key, name, description, rate_limit_policy, retry_policy, auto_disable_policy, degrade_policy, metadata, status
|
||||
)
|
||||
VALUES (
|
||||
'default-runtime-v1',
|
||||
'默认运行策略',
|
||||
'默认包含 TPM/RPM/并发、失败重试、自动禁用和优先级降级关键词。',
|
||||
'{"rules":[{"metric":"rpm","limit":120,"windowSeconds":60},{"metric":"tpm_total","limit":240000,"windowSeconds":60},{"metric":"concurrent","limit":6,"leaseTtlSeconds":120}]}'::jsonb,
|
||||
'{"enabled":true,"maxAttempts":2,"allowKeywords":["rate_limit","timeout","server_error","network","429","5xx"],"denyKeywords":["invalid_api_key","insufficient_quota","billing_not_active","permission_denied"]}'::jsonb,
|
||||
'{"enabled":false,"threshold":3,"windowSeconds":300,"keywords":["invalid_api_key","account_deactivated","permission_denied","billing_not_active"]}'::jsonb,
|
||||
'{"enabled":true,"cooldownSeconds":300,"keywords":["rate_limit","quota","timeout","temporarily_unavailable","overloaded"]}'::jsonb,
|
||||
'{"seed":"0012_runtime_policy_sets"}'::jsonb,
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (policy_key) DO NOTHING;
|
||||
|
||||
ALTER TABLE IF EXISTS base_model_catalog
|
||||
ADD COLUMN IF NOT EXISTS runtime_policy_set_id uuid REFERENCES model_runtime_policy_sets(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS runtime_policy_override jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
ALTER TABLE IF EXISTS platform_models
|
||||
ADD COLUMN IF NOT EXISTS runtime_policy_set_id uuid REFERENCES model_runtime_policy_sets(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS runtime_policy_override jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_base_model_catalog_runtime_policy
|
||||
ON base_model_catalog(runtime_policy_set_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_models_runtime_policy
|
||||
ON platform_models(runtime_policy_set_id);
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET runtime_policy_set_id = (
|
||||
SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1
|
||||
)
|
||||
WHERE runtime_policy_set_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1');
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS gateway_access_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject_type text NOT NULL CHECK (subject_type IN ('user_group', 'tenant', 'user', 'api_key')),
|
||||
subject_id uuid NOT NULL,
|
||||
resource_type text NOT NULL CHECK (resource_type IN ('platform', 'platform_model', 'base_model')),
|
||||
resource_id uuid NOT NULL,
|
||||
effect text NOT NULL CHECK (effect IN ('allow', 'deny')),
|
||||
priority integer NOT NULL DEFAULT 100,
|
||||
min_permission_level integer NOT NULL DEFAULT 0,
|
||||
conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (subject_type, subject_id, resource_type, resource_id, effect)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_subject
|
||||
ON gateway_access_rules(subject_type, subject_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_resource
|
||||
ON gateway_access_rules(resource_type, resource_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_effect
|
||||
ON gateway_access_rules(effect, status, priority);
|
||||
@@ -0,0 +1,73 @@
|
||||
ALTER TABLE IF EXISTS base_model_catalog
|
||||
ADD COLUMN IF NOT EXISTS catalog_type text NOT NULL DEFAULT 'system',
|
||||
ADD COLUMN IF NOT EXISTS default_snapshot jsonb,
|
||||
ADD COLUMN IF NOT EXISTS customized_at timestamptz;
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET catalog_type = CASE
|
||||
WHEN metadata ->> 'source' = 'server-main.integration-platform'
|
||||
OR metadata ? 'sourceProviderCode'
|
||||
OR metadata ? 'rawModel'
|
||||
THEN 'system'
|
||||
ELSE 'custom'
|
||||
END;
|
||||
|
||||
UPDATE base_model_catalog
|
||||
SET default_snapshot = jsonb_build_object(
|
||||
'providerKey', provider_key,
|
||||
'canonicalModelKey', canonical_model_key,
|
||||
'providerModelName', provider_model_name,
|
||||
'modelType', CASE
|
||||
WHEN jsonb_typeof(capabilities -> 'originalTypes') = 'array' THEN capabilities -> 'originalTypes'
|
||||
ELSE jsonb_build_array(model_type)
|
||||
END,
|
||||
'modelAlias', display_name,
|
||||
'capabilities', capabilities,
|
||||
'baseBillingConfig', base_billing_config,
|
||||
'defaultRateLimitPolicy', default_rate_limit_policy,
|
||||
'pricingRuleSetId', COALESCE(pricing_rule_set_id::text, ''),
|
||||
'runtimePolicySetId', COALESCE(runtime_policy_set_id::text, ''),
|
||||
'runtimePolicyOverride', runtime_policy_override,
|
||||
'metadata', metadata,
|
||||
'pricingVersion', pricing_version,
|
||||
'status', status
|
||||
)
|
||||
WHERE catalog_type = 'system'
|
||||
AND COALESCE(default_snapshot, '{}'::jsonb) = '{}'::jsonb;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fill_system_base_model_default_snapshot()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.catalog_type = 'system' AND NEW.default_snapshot IS NULL THEN
|
||||
NEW.default_snapshot = jsonb_build_object(
|
||||
'providerKey', NEW.provider_key,
|
||||
'canonicalModelKey', NEW.canonical_model_key,
|
||||
'providerModelName', NEW.provider_model_name,
|
||||
'modelType', CASE
|
||||
WHEN jsonb_typeof(NEW.capabilities -> 'originalTypes') = 'array' THEN NEW.capabilities -> 'originalTypes'
|
||||
ELSE jsonb_build_array(NEW.model_type)
|
||||
END,
|
||||
'modelAlias', NEW.display_name,
|
||||
'capabilities', NEW.capabilities,
|
||||
'baseBillingConfig', NEW.base_billing_config,
|
||||
'defaultRateLimitPolicy', NEW.default_rate_limit_policy,
|
||||
'pricingRuleSetId', COALESCE(NEW.pricing_rule_set_id::text, ''),
|
||||
'runtimePolicySetId', COALESCE(NEW.runtime_policy_set_id::text, ''),
|
||||
'runtimePolicyOverride', NEW.runtime_policy_override,
|
||||
'metadata', NEW.metadata,
|
||||
'pricingVersion', NEW.pricing_version,
|
||||
'status', NEW.status
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_fill_system_base_model_default_snapshot ON base_model_catalog;
|
||||
CREATE TRIGGER trg_fill_system_base_model_default_snapshot
|
||||
BEFORE INSERT ON base_model_catalog
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION fill_system_base_model_default_snapshot();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_base_model_catalog_type
|
||||
ON base_model_catalog(catalog_type, status);
|
||||
@@ -0,0 +1,41 @@
|
||||
UPDATE base_model_catalog
|
||||
SET default_snapshot = default_snapshot
|
||||
|| jsonb_build_object(
|
||||
'modelType', CASE
|
||||
WHEN jsonb_typeof(default_snapshot -> 'modelType') = 'array' THEN default_snapshot -> 'modelType'
|
||||
WHEN COALESCE(default_snapshot ->> 'modelType', '') <> '' THEN jsonb_build_array(default_snapshot ->> 'modelType')
|
||||
WHEN jsonb_typeof(capabilities -> 'originalTypes') = 'array' THEN capabilities -> 'originalTypes'
|
||||
ELSE jsonb_build_array(model_type)
|
||||
END,
|
||||
'modelAlias', COALESCE(default_snapshot ->> 'modelAlias', default_snapshot ->> 'displayName', display_name)
|
||||
)
|
||||
WHERE catalog_type = 'system'
|
||||
AND COALESCE(default_snapshot, '{}'::jsonb) <> '{}'::jsonb;
|
||||
|
||||
CREATE OR REPLACE FUNCTION fill_system_base_model_default_snapshot()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.catalog_type = 'system' AND NEW.default_snapshot IS NULL THEN
|
||||
NEW.default_snapshot = jsonb_build_object(
|
||||
'providerKey', NEW.provider_key,
|
||||
'canonicalModelKey', NEW.canonical_model_key,
|
||||
'providerModelName', NEW.provider_model_name,
|
||||
'modelType', CASE
|
||||
WHEN jsonb_typeof(NEW.capabilities -> 'originalTypes') = 'array' THEN NEW.capabilities -> 'originalTypes'
|
||||
ELSE jsonb_build_array(NEW.model_type)
|
||||
END,
|
||||
'modelAlias', NEW.display_name,
|
||||
'capabilities', NEW.capabilities,
|
||||
'baseBillingConfig', NEW.base_billing_config,
|
||||
'defaultRateLimitPolicy', NEW.default_rate_limit_policy,
|
||||
'pricingRuleSetId', COALESCE(NEW.pricing_rule_set_id::text, ''),
|
||||
'runtimePolicySetId', COALESCE(NEW.runtime_policy_set_id::text, ''),
|
||||
'runtimePolicyOverride', NEW.runtime_policy_override,
|
||||
'metadata', NEW.metadata,
|
||||
'pricingVersion', NEW.pricing_version,
|
||||
'status', NEW.status
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE gateway_api_keys
|
||||
ADD COLUMN IF NOT EXISTS key_secret text;
|
||||
@@ -9,7 +9,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/api",
|
||||
"command": "node ../../scripts/go-watch.mjs -- go run ./cmd/gateway"
|
||||
"command": "GO_WATCH_PRESTART='go run ./cmd/migrate' node ../../scripts/go-watch.mjs -- go run ./cmd/gateway"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
|
||||
@@ -10,20 +10,33 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.14.0",
|
||||
"@assistant-ui/react-streamdown": "^0.3.0",
|
||||
"@easyai-ai-gateway/contracts": "workspace:*",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
+487
-93
@@ -1,9 +1,14 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
CatalogProvider,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayTask,
|
||||
GatewayUserUpsertRequest,
|
||||
GatewayTenant,
|
||||
GatewayUser,
|
||||
IntegrationPlatform,
|
||||
@@ -11,21 +16,36 @@ import type {
|
||||
PricingRule,
|
||||
PricingRuleSet,
|
||||
RateLimitWindow,
|
||||
RuntimePolicySet,
|
||||
UserGroupUpsertRequest,
|
||||
UserGroup,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
batchAccessRules,
|
||||
createAccessRule,
|
||||
createApiKey,
|
||||
createGatewayUser,
|
||||
createPlatform,
|
||||
createPlatformModel,
|
||||
createTenant,
|
||||
createUserGroup,
|
||||
deleteAccessRule,
|
||||
deleteGatewayUser,
|
||||
deletePlatform,
|
||||
deleteTenant,
|
||||
deleteUserGroup,
|
||||
getHealth,
|
||||
getTask,
|
||||
listAccessRules,
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
listModels,
|
||||
listPlayableApiKeys,
|
||||
listPlayableModels,
|
||||
listPlatforms,
|
||||
listPricingRules,
|
||||
listPricingRuleSets,
|
||||
listRuntimePolicySets,
|
||||
listPublicBaseModels,
|
||||
listPublicCatalogProviders,
|
||||
listRateLimitWindows,
|
||||
@@ -34,25 +54,34 @@ import {
|
||||
listUsers,
|
||||
loginLocalAccount,
|
||||
registerLocalAccount,
|
||||
replacePlatformModels,
|
||||
type HealthResponse,
|
||||
updateAccessRule,
|
||||
updateGatewayUser,
|
||||
updatePlatform,
|
||||
updateTenant,
|
||||
updateUserGroup,
|
||||
} from './api';
|
||||
import type { ConsoleData, StatItem } from './app-state';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { LoginRequiredPanel } from './components/LoginRequiredPanel';
|
||||
import { useCatalogOperations } from './hooks/useCatalogOperations';
|
||||
import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations';
|
||||
import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations';
|
||||
import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage';
|
||||
import { runTask } from './lib/run-task';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { ModelsPage } from './pages/ModelsPage';
|
||||
import { PlaygroundPage } from './pages/PlaygroundPage';
|
||||
import { WorkspacePage } from './pages/WorkspacePage';
|
||||
import {
|
||||
parseAppRoute,
|
||||
pathForAdminSection,
|
||||
pathForApiDocSection,
|
||||
pathForPage,
|
||||
pathForPlaygroundMode,
|
||||
pathForWorkspaceSection,
|
||||
type AppRouteState,
|
||||
} from './routing';
|
||||
@@ -64,19 +93,41 @@ import type {
|
||||
LoadState,
|
||||
LoginForm,
|
||||
PageKey,
|
||||
PlatformForm,
|
||||
PlatformModelForm,
|
||||
PlaygroundMode,
|
||||
PlatformCreateInput,
|
||||
PlatformModelBindingInput,
|
||||
PlatformWithModelsInput,
|
||||
RegisterForm,
|
||||
TaskForm,
|
||||
WorkspaceSection,
|
||||
} from './types';
|
||||
|
||||
type DataKey =
|
||||
| 'health'
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
| 'platforms'
|
||||
| 'models'
|
||||
| 'providers'
|
||||
| 'baseModels'
|
||||
| 'pricingRules'
|
||||
| 'pricingRuleSets'
|
||||
| 'runtimePolicySets'
|
||||
| 'rateLimitWindows'
|
||||
| 'tenants'
|
||||
| 'users'
|
||||
| 'userGroups'
|
||||
| 'accessRules'
|
||||
| 'apiKeys';
|
||||
|
||||
export function App() {
|
||||
const initialRoute = parseAppRoute();
|
||||
const [activePage, setActivePage] = useState<PageKey>(initialRoute.activePage);
|
||||
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
||||
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
||||
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
|
||||
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
|
||||
const [token, setToken] = useState(readStoredAccessToken);
|
||||
const [externalToken, setExternalToken] = useState('');
|
||||
const [authMode, setAuthMode] = useState<AuthMode>('login');
|
||||
@@ -85,10 +136,13 @@ export function App() {
|
||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
||||
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
|
||||
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
|
||||
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
|
||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
||||
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
|
||||
const [users, setUsers] = useState<GatewayUser[]>([]);
|
||||
@@ -96,15 +150,17 @@ export function App() {
|
||||
const [apiKeys, setApiKeys] = useState<GatewayApiKey[]>([]);
|
||||
const [apiKeyForm, setApiKeyForm] = useState<ApiKeyForm>({ name: 'Local smoke key' });
|
||||
const [apiKeySecret, setApiKeySecret] = useState('');
|
||||
const [platformForm, setPlatformForm] = useState<PlatformForm>({ provider: 'openai', platformKey: 'openai-simulation', name: 'OpenAI Simulation', baseUrl: 'https://api.openai.com/v1', pricingRuleSetId: '', defaultDiscountFactor: '1' });
|
||||
const [platformModelForm, setPlatformModelForm] = useState<PlatformModelForm>({ platformId: '', canonicalModelKey: '', modelName: 'gpt-4o-mini', modelAlias: 'gpt-4o-mini', modelType: 'chat', pricingRuleSetId: '', discountFactor: '' });
|
||||
const [apiKeySecretsById, setApiKeySecretsById] = useState<Record<string, string>>({});
|
||||
const [selectedPlaygroundApiKeyId, setSelectedPlaygroundApiKeyId] = useState('');
|
||||
const [taskForm, setTaskForm] = useState<TaskForm>({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' });
|
||||
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
||||
const [coreState, setCoreState] = useState<LoadState>('idle');
|
||||
const [coreMessage, setCoreMessage] = useState('');
|
||||
const [state, setState] = useState<LoadState>('idle');
|
||||
const [error, setError] = useState('');
|
||||
const { removeBaseModel, removeProvider, saveBaseModel, saveProvider } = useCatalogOperations({
|
||||
const loadedDataKeysRef = useRef(new Set<DataKey>());
|
||||
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
||||
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
|
||||
setBaseModels,
|
||||
setCoreMessage,
|
||||
setCoreState,
|
||||
@@ -117,15 +173,19 @@ export function App() {
|
||||
setPricingRuleSets,
|
||||
token,
|
||||
});
|
||||
const { removeRuntimePolicySet, saveRuntimePolicySet } = useRuntimePolicySetOperations({
|
||||
setCoreMessage,
|
||||
setCoreState,
|
||||
setRuntimePolicySets,
|
||||
token,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadPublicCatalog();
|
||||
void ensureData(['health']);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
void refresh(token);
|
||||
}
|
||||
}, []);
|
||||
void ensureRouteData(token);
|
||||
}, [activePage, adminSection, workspaceSection, token]);
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
applyRoute(parseAppRoute());
|
||||
@@ -147,11 +207,14 @@ export function App() {
|
||||
{ label: '平台模型', value: enabledModels, tone: 'violet' },
|
||||
{ label: 'Provider', value: activeProviders || providers.length, tone: 'amber' },
|
||||
{ label: '定价规则', value: pricingRules.length, tone: 'cyan' },
|
||||
{ label: '运行策略', value: runtimePolicySets.length, tone: 'slate' },
|
||||
{ label: '访问规则', value: accessRules.length, tone: 'amber' },
|
||||
{ label: '限流窗口', value: activeRateWindows, tone: 'rose' },
|
||||
];
|
||||
}, [models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]);
|
||||
}, [accessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]);
|
||||
|
||||
const data = useMemo<ConsoleData>(() => ({
|
||||
accessRules,
|
||||
apiKeys,
|
||||
baseModels,
|
||||
models,
|
||||
@@ -160,58 +223,108 @@ export function App() {
|
||||
pricingRuleSets,
|
||||
providers,
|
||||
rateLimitWindows,
|
||||
runtimePolicySets,
|
||||
taskResult,
|
||||
tenants,
|
||||
userGroups,
|
||||
users,
|
||||
}), [apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, taskResult, tenants, userGroups, users]);
|
||||
}), [accessRules, apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tenants, userGroups, users]);
|
||||
|
||||
async function refresh(nextToken = token) {
|
||||
await ensureRouteData(nextToken, true);
|
||||
}
|
||||
|
||||
async function ensureRouteData(nextToken = token, force = false) {
|
||||
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
|
||||
}
|
||||
|
||||
async function ensureData(keys: DataKey[], nextToken = token, force = false) {
|
||||
const uniqueKeys = Array.from(new Set(keys));
|
||||
const requestKeys = uniqueKeys.filter((key) => {
|
||||
if (!force && loadedDataKeysRef.current.has(key)) return false;
|
||||
if (loadingDataKeysRef.current.has(key)) return false;
|
||||
return key === 'health' || key === 'publicCatalog' || Boolean(nextToken);
|
||||
});
|
||||
if (requestKeys.length === 0) return;
|
||||
|
||||
requestKeys.forEach((key) => loadingDataKeysRef.current.add(key));
|
||||
setState('loading');
|
||||
setError('');
|
||||
try {
|
||||
const responses = await Promise.all([
|
||||
listPlatforms(nextToken),
|
||||
listModels(nextToken),
|
||||
listCatalogProviders(nextToken),
|
||||
listBaseModels(nextToken),
|
||||
listPricingRules(nextToken),
|
||||
listPricingRuleSets(nextToken),
|
||||
listRateLimitWindows(nextToken),
|
||||
listTenants(nextToken),
|
||||
listUsers(nextToken),
|
||||
listUserGroups(nextToken),
|
||||
listApiKeys(nextToken),
|
||||
]);
|
||||
setPlatforms(responses[0].items);
|
||||
setModels(responses[1].items);
|
||||
setProviders(responses[2].items);
|
||||
setBaseModels(responses[3].items);
|
||||
setPricingRules(responses[4].items);
|
||||
setPricingRuleSets(responses[5].items);
|
||||
setRateLimitWindows(responses[6].items);
|
||||
setTenants(responses[7].items);
|
||||
setUsers(responses[8].items);
|
||||
setUserGroups(responses[9].items);
|
||||
setApiKeys(responses[10].items);
|
||||
await Promise.all(requestKeys.map((key) => loadDataKey(key, nextToken)));
|
||||
requestKeys.forEach((key) => loadedDataKeysRef.current.add(key));
|
||||
setState('ready');
|
||||
} catch (err) {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
requestKeys.forEach((key) => loadingDataKeysRef.current.delete(key));
|
||||
}
|
||||
}
|
||||
async function loadPublicCatalog() {
|
||||
try {
|
||||
const [healthResult, providersResult, baseModelsResult] = await Promise.all([
|
||||
getHealth(),
|
||||
listPublicCatalogProviders(),
|
||||
listPublicBaseModels(),
|
||||
]);
|
||||
setHealth(healthResult);
|
||||
setProviders(providersResult.items);
|
||||
setBaseModels(baseModelsResult.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '公共模型目录加载失败');
|
||||
|
||||
async function loadDataKey(key: DataKey, nextToken: string) {
|
||||
switch (key) {
|
||||
case 'health': {
|
||||
setHealth(await getHealth());
|
||||
return;
|
||||
}
|
||||
case 'publicCatalog': {
|
||||
const [providersResult, baseModelsResult] = await Promise.all([
|
||||
listPublicCatalogProviders(),
|
||||
listPublicBaseModels(),
|
||||
]);
|
||||
setProviders(providersResult.items);
|
||||
setBaseModels(baseModelsResult.items);
|
||||
return;
|
||||
}
|
||||
case 'platforms':
|
||||
setPlatforms((await listPlatforms(nextToken)).items);
|
||||
return;
|
||||
case 'models':
|
||||
setModels((await listModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundModels':
|
||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundApiKeys': {
|
||||
const response = await listPlayableApiKeys(nextToken);
|
||||
setApiKeys(response.items);
|
||||
setApiKeySecretsById(Object.fromEntries(response.items.map((item) => [item.id, item.secret])));
|
||||
setSelectedPlaygroundApiKeyId((current) => current && response.items.some((item) => item.id === current) ? current : response.items[0]?.id ?? '');
|
||||
return;
|
||||
}
|
||||
case 'providers':
|
||||
setProviders((await listCatalogProviders(nextToken)).items);
|
||||
return;
|
||||
case 'baseModels':
|
||||
setBaseModels((await listBaseModels(nextToken)).items);
|
||||
return;
|
||||
case 'pricingRules':
|
||||
setPricingRules((await listPricingRules(nextToken)).items);
|
||||
return;
|
||||
case 'pricingRuleSets':
|
||||
setPricingRuleSets((await listPricingRuleSets(nextToken)).items);
|
||||
return;
|
||||
case 'runtimePolicySets':
|
||||
setRuntimePolicySets((await listRuntimePolicySets(nextToken)).items);
|
||||
return;
|
||||
case 'rateLimitWindows':
|
||||
setRateLimitWindows((await listRateLimitWindows(nextToken)).items);
|
||||
return;
|
||||
case 'tenants':
|
||||
setTenants((await listTenants(nextToken)).items);
|
||||
return;
|
||||
case 'users':
|
||||
setUsers((await listUsers(nextToken)).items);
|
||||
return;
|
||||
case 'userGroups':
|
||||
setUserGroups((await listUserGroups(nextToken)).items);
|
||||
return;
|
||||
case 'accessRules':
|
||||
setAccessRules((await listAccessRules(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeys':
|
||||
setApiKeys((await listApiKeys(nextToken)).items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +347,7 @@ export function App() {
|
||||
}
|
||||
persistAccessToken(nextToken);
|
||||
setToken(nextToken);
|
||||
await refresh(nextToken);
|
||||
await ensureRouteData(nextToken, true);
|
||||
}
|
||||
|
||||
async function authenticate(request: () => Promise<{ accessToken: string }>, fallback: string) {
|
||||
@@ -244,7 +357,7 @@ export function App() {
|
||||
const response = await request();
|
||||
persistAccessToken(response.accessToken);
|
||||
setToken(response.accessToken);
|
||||
await refresh(response.accessToken);
|
||||
await ensureRouteData(response.accessToken, true);
|
||||
} catch (err) {
|
||||
setState('error');
|
||||
setError(err instanceof Error ? err.message : fallback);
|
||||
@@ -258,6 +371,8 @@ export function App() {
|
||||
try {
|
||||
const response = await createApiKey(token, { name: apiKeyForm.name, scopes: ['chat', 'image', 'video'] });
|
||||
setApiKeySecret(response.secret);
|
||||
setApiKeySecretsById((current) => ({ ...current, [response.apiKey.id]: response.secret }));
|
||||
setSelectedPlaygroundApiKeyId(response.apiKey.id);
|
||||
setApiKeys((current) => [response.apiKey, ...current.filter((item) => item.id !== response.apiKey.id)]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage('API Key 已创建,secret 仅展示一次。');
|
||||
@@ -267,54 +382,183 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlatform(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function savePlatformWithModels(input: PlatformWithModelsInput) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const platform = await createPlatform(token, {
|
||||
...platformForm,
|
||||
authType: 'bearer',
|
||||
config: { testMode: true },
|
||||
credentials: { mode: 'simulation' },
|
||||
defaultDiscountFactor: Number(platformForm.defaultDiscountFactor) || 1,
|
||||
pricingRuleSetId: platformForm.pricingRuleSetId || undefined,
|
||||
});
|
||||
setPlatforms((current) => [platform, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setPlatformModelForm((current) => ({ ...current, platformId: current.platformId || platform.id }));
|
||||
const platform = input.platformId
|
||||
? await updatePlatform(token, input.platformId, input.platform)
|
||||
: await createPlatform(token, input.platform);
|
||||
const platformForState = withCredentialPreviewFallback(
|
||||
platform,
|
||||
input.platform,
|
||||
input.platformId ? platforms.find((item) => item.id === input.platformId) : undefined,
|
||||
);
|
||||
const modelBindings = input.models.map((modelInput) => mergeExistingPlatformModelInput(modelInput, models, platform.id));
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已创建,当前阶段平台凭证仅全局管理员可配置。');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
: `平台已创建,已绑定 ${input.models.length} 个模型。`);
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '创建平台失败');
|
||||
setCoreMessage(err instanceof Error ? err.message : input.platformId ? '更新平台失败' : '创建平台失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlatformModel(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!platformModelForm.platformId || !platformModelForm.modelName || !platformModelForm.modelType) {
|
||||
setCoreState('error');
|
||||
setCoreMessage('请选择平台并填写模型名。');
|
||||
return;
|
||||
}
|
||||
async function removePlatform(platformId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const model = await createPlatformModel(token, platformModelForm.platformId, {
|
||||
canonicalModelKey: platformModelForm.canonicalModelKey || undefined,
|
||||
modelAlias: platformModelForm.modelAlias || platformModelForm.modelName,
|
||||
modelName: platformModelForm.modelName,
|
||||
modelType: platformModelForm.modelType,
|
||||
discountFactor: Number(platformModelForm.discountFactor) || undefined,
|
||||
pricingRuleSetId: platformModelForm.pricingRuleSetId || undefined,
|
||||
retryPolicy: { enabled: true, maxAttempts: 2 },
|
||||
});
|
||||
setModels((current) => [model, ...current.filter((item) => item.id !== model.id)]);
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台模型已绑定,可参与 simulation 任务路由。');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '绑定平台模型失败');
|
||||
setCoreMessage(err instanceof Error ? err.message : '删除平台失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTenant(input: GatewayTenantUpsertRequest, tenantId?: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : tenantId ? '更新租户失败' : '创建租户失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTenant(tenantId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
await deleteTenant(token, tenantId);
|
||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||
setCoreState('ready');
|
||||
setCoreMessage('租户已删除。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '删除租户失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(input: GatewayUserUpsertRequest, userId?: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : userId ? '更新用户失败' : '创建用户失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(userId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
await deleteGatewayUser(token, userId);
|
||||
setUsers((current) => current.filter((user) => user.id !== userId));
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户已删除。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '删除用户失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : groupId ? '更新用户组失败' : '创建用户组失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUserGroup(groupId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
await deleteUserGroup(token, groupId);
|
||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户组已删除。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '删除用户组失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||
setCoreState('ready');
|
||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : ruleId ? '更新访问权限失败' : '创建访问权限失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAccessRule(ruleId: string) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
await deleteAccessRule(token, ruleId);
|
||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限规则已删除。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '删除访问权限失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function batchSaveAccessRules(input: GatewayAccessRuleBatchRequest) {
|
||||
setCoreState('loading');
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await batchAccessRules(token, input);
|
||||
setAccessRules(response.items);
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限已更新。');
|
||||
} catch (err) {
|
||||
setCoreState('error');
|
||||
setCoreMessage(err instanceof Error ? err.message : '批量更新访问权限失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,19 +582,26 @@ export function App() {
|
||||
function signOut() {
|
||||
persistAccessToken('');
|
||||
setToken('');
|
||||
loadedDataKeysRef.current = new Set(health ? ['health'] : []);
|
||||
loadingDataKeysRef.current.clear();
|
||||
setState('idle');
|
||||
setPlatforms([]);
|
||||
setModels([]);
|
||||
setPlaygroundModels([]);
|
||||
setProviders([]);
|
||||
setBaseModels([]);
|
||||
setPricingRules([]);
|
||||
setPricingRuleSets([]);
|
||||
setRuntimePolicySets([]);
|
||||
setAccessRules([]);
|
||||
setRateLimitWindows([]);
|
||||
setTenants([]);
|
||||
setUsers([]);
|
||||
setUserGroups([]);
|
||||
setApiKeys([]);
|
||||
setApiKeySecret('');
|
||||
setApiKeySecretsById({});
|
||||
setSelectedPlaygroundApiKeyId('');
|
||||
setTaskResult(null);
|
||||
setCoreMessage('');
|
||||
navigatePath('/');
|
||||
@@ -362,13 +613,14 @@ export function App() {
|
||||
}
|
||||
|
||||
function currentRouteState(): AppRouteState {
|
||||
return { activePage, adminSection, apiDocSection, workspaceSection };
|
||||
return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection };
|
||||
}
|
||||
|
||||
function applyRoute(route: AppRouteState) {
|
||||
setActivePage(route.activePage);
|
||||
setAdminSection(route.adminSection);
|
||||
setApiDocSection(route.apiDocSection);
|
||||
setPlaygroundMode(route.playgroundMode);
|
||||
setWorkspaceSection(route.workspaceSection);
|
||||
}
|
||||
|
||||
@@ -395,6 +647,19 @@ export function App() {
|
||||
navigatePath(pathForApiDocSection(section));
|
||||
}
|
||||
|
||||
function navigatePlaygroundMode(mode: PlaygroundMode) {
|
||||
navigatePath(pathForPlaygroundMode(mode));
|
||||
}
|
||||
|
||||
function openApiKeyCreation() {
|
||||
navigatePath(pathForWorkspaceSection('apiKeys'));
|
||||
}
|
||||
|
||||
function useApiKeyForPlayground(apiKeyId?: string) {
|
||||
if (apiKeyId) setSelectedPlaygroundApiKeyId(apiKeyId);
|
||||
navigatePath(pathForPlaygroundMode('chat'));
|
||||
}
|
||||
|
||||
const isAuthenticated = Boolean(token);
|
||||
|
||||
return (
|
||||
@@ -409,7 +674,21 @@ export function App() {
|
||||
onSignOut={signOut}
|
||||
>
|
||||
{error && <div className="notice">{error}</div>}
|
||||
{activePage === 'home' && <HomePage onNavigate={navigatePage} />}
|
||||
{activePage === 'home' && <HomePage onNavigate={navigatePage} onPlaygroundMode={navigatePlaygroundMode} />}
|
||||
{activePage === 'playground' && (
|
||||
<PlaygroundPage
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeys={apiKeys}
|
||||
mode={playgroundMode}
|
||||
models={playgroundModels}
|
||||
selectedApiKeyId={selectedPlaygroundApiKeyId}
|
||||
token={token}
|
||||
onApiKeyChange={setSelectedPlaygroundApiKeyId}
|
||||
onCreateApiKey={openApiKeyCreation}
|
||||
onLogin={showLogin}
|
||||
onModeChange={navigatePlaygroundMode}
|
||||
/>
|
||||
)}
|
||||
{activePage === 'models' && <ModelsPage data={data} />}
|
||||
{activePage === 'workspace' && (
|
||||
isAuthenticated ? (
|
||||
@@ -422,6 +701,7 @@ export function App() {
|
||||
onApiKeyFormChange={setApiKeyForm}
|
||||
onSectionChange={navigateWorkspaceSection}
|
||||
onSubmitApiKey={submitAPIKey}
|
||||
onUseApiKeyForPlayground={useApiKeyForPlayground}
|
||||
/>
|
||||
) : (
|
||||
<LoginRequiredPanel
|
||||
@@ -445,22 +725,31 @@ export function App() {
|
||||
<AdminPage
|
||||
data={data}
|
||||
operationMessage={coreMessage}
|
||||
platformForm={platformForm}
|
||||
platformModelForm={platformModelForm}
|
||||
section={adminSection}
|
||||
stats={stats}
|
||||
state={coreState}
|
||||
onDeleteBaseModel={removeBaseModel}
|
||||
onDeletePlatform={removePlatform}
|
||||
onDeleteProvider={removeProvider}
|
||||
onDeletePricingRuleSet={removePricingRuleSet}
|
||||
onPlatformFormChange={setPlatformForm}
|
||||
onPlatformModelFormChange={setPlatformModelForm}
|
||||
onDeleteRuntimePolicySet={removeRuntimePolicySet}
|
||||
onDeleteAccessRule={removeAccessRule}
|
||||
onDeleteTenant={removeTenant}
|
||||
onDeleteUser={removeUser}
|
||||
onDeleteUserGroup={removeUserGroup}
|
||||
onSaveBaseModel={saveBaseModel}
|
||||
onResetAllBaseModels={resetAllBaseModelsToDefault}
|
||||
onResetBaseModel={resetBaseModelToDefault}
|
||||
onSavePlatform={savePlatformWithModels}
|
||||
onSaveProvider={saveProvider}
|
||||
onSavePricingRuleSet={savePricingRuleSet}
|
||||
onSaveRuntimePolicySet={saveRuntimePolicySet}
|
||||
onBatchAccessRules={batchSaveAccessRules}
|
||||
onSaveAccessRule={saveAccessRule}
|
||||
onSaveTenant={saveTenant}
|
||||
onSaveUser={saveUser}
|
||||
onSaveUserGroup={saveUserGroup}
|
||||
onSectionChange={navigateAdminSection}
|
||||
onSubmitPlatform={submitPlatform}
|
||||
onSubmitPlatformModel={submitPlatformModel}
|
||||
/>
|
||||
) : (
|
||||
<LoginRequiredPanel
|
||||
@@ -497,3 +786,108 @@ export function App() {
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function platformModelIsSelected(model: PlatformModel, selectedModels: PlatformModelBindingInput[]) {
|
||||
return selectedModels.some((selected) => {
|
||||
if (selected.baseModelId && model.baseModelId) return selected.baseModelId === model.baseModelId;
|
||||
return selected.modelName === model.modelName && selected.modelType === model.modelType;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, currentModels: PlatformModel[], platformId: string): PlatformModelBindingInput {
|
||||
const existing = currentModels.find((model) => model.platformId === platformId && platformModelIsSelected(model, [input]));
|
||||
if (!existing) return input;
|
||||
return {
|
||||
...input,
|
||||
discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined,
|
||||
pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId,
|
||||
rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy,
|
||||
retryPolicy: input.retryPolicy ?? existing.retryPolicy,
|
||||
runtimePolicyOverride: input.runtimePolicyOverride ?? (existing.runtimePolicyOverride as Record<string, unknown> | undefined),
|
||||
runtimePolicySetId: input.runtimePolicySetId ?? existing.runtimePolicySetId,
|
||||
};
|
||||
}
|
||||
|
||||
function withCredentialPreviewFallback(
|
||||
platform: IntegrationPlatform,
|
||||
input: PlatformCreateInput,
|
||||
existing?: IntegrationPlatform,
|
||||
): IntegrationPlatform {
|
||||
const responsePreview = nonEmptyRecord(platform.credentialsPreview);
|
||||
if (responsePreview) return platform;
|
||||
if (input.credentials !== undefined) {
|
||||
const inputPreview = maskCredentialsPreview(input.credentials);
|
||||
return { ...platform, credentialsPreview: inputPreview ?? {} };
|
||||
}
|
||||
const existingPreview = nonEmptyRecord(existing?.credentialsPreview);
|
||||
return existingPreview ? { ...platform, credentialsPreview: existingPreview } : platform;
|
||||
}
|
||||
|
||||
function maskCredentialsPreview(credentials: Record<string, unknown> | undefined) {
|
||||
if (!credentials || Object.keys(credentials).length === 0) return undefined;
|
||||
return Object.fromEntries(Object.entries(credentials).map(([key, value]) => [key, maskCredentialValue(value)]));
|
||||
}
|
||||
|
||||
function maskCredentialValue(value: unknown): unknown {
|
||||
if (typeof value === 'string') return maskSecret(value);
|
||||
if (Array.isArray(value)) return value.map(maskCredentialValue);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, nested]) => [key, maskCredentialValue(nested)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function maskSecret(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.length <= 6) return '*'.repeat(trimmed.length);
|
||||
return `${trimmed.slice(0, 3)}${'*'.repeat(trimmed.length - 6)}${trimmed.slice(-3)}`;
|
||||
}
|
||||
|
||||
function nonEmptyRecord(value: Record<string, unknown> | undefined) {
|
||||
return value && Object.keys(value).length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function dataKeysForRoute(
|
||||
activePage: PageKey,
|
||||
adminSection: AdminSection,
|
||||
workspaceSection: WorkspaceSection,
|
||||
isAuthenticated: boolean,
|
||||
): DataKey[] {
|
||||
if (activePage === 'playground') return isAuthenticated ? ['playgroundModels', 'playgroundApiKeys'] : [];
|
||||
if (activePage === 'models') return ['publicCatalog'];
|
||||
if (activePage === 'home' || activePage === 'docs') return [];
|
||||
if (!isAuthenticated) return [];
|
||||
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
||||
if (workspaceSection === 'apiKeys') return ['apiKeys'];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (activePage !== 'admin') return [];
|
||||
switch (adminSection) {
|
||||
case 'overview':
|
||||
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'tenants', 'users', 'userGroups', 'accessRules'];
|
||||
case 'globalModels':
|
||||
return ['providers'];
|
||||
case 'pricing':
|
||||
return ['pricingRuleSets'];
|
||||
case 'runtime':
|
||||
return ['runtimePolicySets'];
|
||||
case 'baseModels':
|
||||
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
||||
case 'platforms':
|
||||
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets'];
|
||||
case 'tenants':
|
||||
return ['tenants', 'userGroups'];
|
||||
case 'users':
|
||||
return ['users', 'tenants', 'userGroups'];
|
||||
case 'userGroups':
|
||||
return ['userGroups'];
|
||||
case 'accessRules':
|
||||
return ['accessRules', 'userGroups', 'platforms', 'models'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+281
-28
@@ -5,19 +5,29 @@ import type {
|
||||
CatalogProvider,
|
||||
CatalogProviderUpsertRequest,
|
||||
CreatedGatewayApiKey,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRule,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayApiKey,
|
||||
GatewayTenant,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayTask,
|
||||
GatewayUser,
|
||||
GatewayUserUpsertRequest,
|
||||
IntegrationPlatform,
|
||||
ListResponse,
|
||||
PlatformModel,
|
||||
PlayableGatewayApiKey,
|
||||
PricingRule,
|
||||
PricingRuleSet,
|
||||
PricingRuleSetUpsertRequest,
|
||||
RateLimitWindow,
|
||||
RuntimePolicySet,
|
||||
RuntimePolicySetUpsertRequest,
|
||||
UserGroup,
|
||||
UserGroupUpsertRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { PlatformCreateInput, PlatformModelBindingInput } from './types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
||||
|
||||
@@ -62,6 +72,10 @@ export async function listModels(token: string): Promise<ListResponse<PlatformMo
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||
}
|
||||
|
||||
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>('/api/v1/playground/models', { token });
|
||||
}
|
||||
|
||||
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
|
||||
return request<ListResponse<CatalogProvider>>('/api/v1/public/catalog/providers', { auth: false });
|
||||
}
|
||||
@@ -128,6 +142,20 @@ export async function updateBaseModel(
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetBaseModel(token: string, baseModelId: string): Promise<BaseModelCatalogItem> {
|
||||
return request<BaseModelCatalogItem>(`/api/v1/catalog/base-models/${baseModelId}/reset`, {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetAllBaseModels(token: string): Promise<ListResponse<BaseModelCatalogItem>> {
|
||||
return request<ListResponse<BaseModelCatalogItem>>('/api/v1/catalog/base-models/reset-all', {
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBaseModel(token: string, baseModelId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/catalog/base-models/${baseModelId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -173,22 +201,168 @@ export async function deletePricingRuleSet(token: string, ruleSetId: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRuntimePolicySets(token: string): Promise<ListResponse<RuntimePolicySet>> {
|
||||
return request<ListResponse<RuntimePolicySet>>('/api/v1/runtime/policy-sets', { token });
|
||||
}
|
||||
|
||||
export async function createRuntimePolicySet(
|
||||
token: string,
|
||||
input: RuntimePolicySetUpsertRequest,
|
||||
): Promise<RuntimePolicySet> {
|
||||
return request<RuntimePolicySet>('/api/v1/runtime/policy-sets', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateRuntimePolicySet(
|
||||
token: string,
|
||||
policySetId: string,
|
||||
input: RuntimePolicySetUpsertRequest,
|
||||
): Promise<RuntimePolicySet> {
|
||||
return request<RuntimePolicySet>(`/api/v1/runtime/policy-sets/${policySetId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRuntimePolicySet(token: string, policySetId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/runtime/policy-sets/${policySetId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTenants(token: string): Promise<ListResponse<GatewayTenant>> {
|
||||
return request<ListResponse<GatewayTenant>>('/api/v1/tenants', { token });
|
||||
}
|
||||
|
||||
export async function createTenant(token: string, input: GatewayTenantUpsertRequest): Promise<GatewayTenant> {
|
||||
return request<GatewayTenant>('/api/v1/tenants', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenant(token: string, tenantId: string, input: GatewayTenantUpsertRequest): Promise<GatewayTenant> {
|
||||
return request<GatewayTenant>(`/api/v1/tenants/${tenantId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTenant(token: string, tenantId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/tenants/${tenantId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(token: string): Promise<ListResponse<GatewayUser>> {
|
||||
return request<ListResponse<GatewayUser>>('/api/v1/users', { token });
|
||||
}
|
||||
|
||||
export async function createGatewayUser(token: string, input: GatewayUserUpsertRequest): Promise<GatewayUser> {
|
||||
return request<GatewayUser>('/api/v1/users', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateGatewayUser(token: string, userId: string, input: GatewayUserUpsertRequest): Promise<GatewayUser> {
|
||||
return request<GatewayUser>(`/api/v1/users/${userId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
|
||||
return request<ListResponse<UserGroup>>('/api/v1/user-groups', { token });
|
||||
}
|
||||
|
||||
export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
||||
return request<UserGroup>('/api/v1/user-groups', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserGroup(token: string, groupId: string, input: UserGroupUpsertRequest): Promise<UserGroup> {
|
||||
return request<UserGroup>(`/api/v1/user-groups/${groupId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserGroup(token: string, groupId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/user-groups/${groupId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAccessRules(token: string): Promise<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules', { token });
|
||||
}
|
||||
|
||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>('/api/v1/access-rules', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise<ListResponse<GatewayAccessRule>> {
|
||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/access-rules/batch', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAccessRule(
|
||||
token: string,
|
||||
ruleId: string,
|
||||
input: GatewayAccessRuleUpsertRequest,
|
||||
): Promise<GatewayAccessRule> {
|
||||
return request<GatewayAccessRule>(`/api/v1/access-rules/${ruleId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAccessRule(token: string, ruleId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/access-rules/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listApiKeys(token: string): Promise<ListResponse<GatewayApiKey>> {
|
||||
return request<ListResponse<GatewayApiKey>>('/api/v1/api-keys', { token });
|
||||
}
|
||||
|
||||
export async function listPlayableApiKeys(token: string): Promise<ListResponse<PlayableGatewayApiKey>> {
|
||||
return request<ListResponse<PlayableGatewayApiKey>>('/api/playground/api-keys', { token });
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
token: string,
|
||||
input: { name: string; scopes?: string[] },
|
||||
@@ -200,22 +374,7 @@ export async function createApiKey(
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatform(
|
||||
token: string,
|
||||
input: {
|
||||
provider: string;
|
||||
platformKey?: string;
|
||||
name: string;
|
||||
baseUrl?: string;
|
||||
authType?: string;
|
||||
credentials?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
defaultPricingMode?: string;
|
||||
defaultDiscountFactor?: number;
|
||||
pricingRuleSetId?: string;
|
||||
priority?: number;
|
||||
},
|
||||
): Promise<IntegrationPlatform> {
|
||||
export async function createPlatform(token: string, input: PlatformCreateInput): Promise<IntegrationPlatform> {
|
||||
return request<IntegrationPlatform>('/api/v1/platforms', {
|
||||
body: input,
|
||||
method: 'POST',
|
||||
@@ -223,21 +382,25 @@ export async function createPlatform(
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePlatform(token: string, platformId: string, input: PlatformCreateInput): Promise<IntegrationPlatform> {
|
||||
return request<IntegrationPlatform>(`/api/v1/platforms/${platformId}`, {
|
||||
body: input,
|
||||
method: 'PATCH',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlatform(token: string, platformId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/platforms/${platformId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformModel(
|
||||
token: string,
|
||||
platformId: string,
|
||||
input: {
|
||||
canonicalModelKey?: string;
|
||||
baseModelId?: string;
|
||||
modelName: string;
|
||||
modelAlias?: string;
|
||||
modelType: string;
|
||||
displayName?: string;
|
||||
retryPolicy?: Record<string, unknown>;
|
||||
rateLimitPolicy?: Record<string, unknown>;
|
||||
pricingRuleSetId?: string;
|
||||
discountFactor?: number;
|
||||
},
|
||||
input: PlatformModelBindingInput,
|
||||
): Promise<PlatformModel> {
|
||||
return request<PlatformModel>(`/api/v1/platforms/${platformId}/models`, {
|
||||
body: input,
|
||||
@@ -246,6 +409,25 @@ export async function createPlatformModel(
|
||||
});
|
||||
}
|
||||
|
||||
export async function replacePlatformModels(
|
||||
token: string,
|
||||
platformId: string,
|
||||
models: PlatformModelBindingInput[],
|
||||
): Promise<ListResponse<PlatformModel>> {
|
||||
return request<ListResponse<PlatformModel>>(`/api/v1/platforms/${platformId}/models`, {
|
||||
body: { models },
|
||||
method: 'PUT',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlatformModel(token: string, modelId: string): Promise<void> {
|
||||
await request<void>(`/api/v1/platform-models/${modelId}`, {
|
||||
method: 'DELETE',
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createChatTask(
|
||||
token: string,
|
||||
input: { model: string; messages: Array<Record<string, unknown>>; runMode?: string; simulation?: boolean },
|
||||
@@ -257,6 +439,57 @@ export async function createChatTask(
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamChatCompletions(
|
||||
token: string,
|
||||
input: { model: string; messages: Array<Record<string, unknown>>; simulation?: boolean },
|
||||
onDelta: (delta: string) => void,
|
||||
): Promise<void> {
|
||||
for await (const delta of streamChatCompletionText(token, input)) {
|
||||
onDelta(delta);
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamChatCompletionText(
|
||||
token: string,
|
||||
input: { model: string; messages: Array<Record<string, unknown>>; simulation?: boolean },
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string> {
|
||||
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
||||
body: JSON.stringify({ ...input, stream: true }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split(/\n\n/);
|
||||
buffer = events.pop() ?? '';
|
||||
for (const eventBlock of events) {
|
||||
const delta = parseSSEBlockDelta(eventBlock);
|
||||
if (delta) yield delta;
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const delta = parseSSEBlockDelta(buffer);
|
||||
if (delta) yield delta;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createImageGenerationTask(
|
||||
token: string,
|
||||
input: { model: string; prompt: string; size?: string; quality?: string; runMode?: string; simulation?: boolean },
|
||||
@@ -335,3 +568,23 @@ function parseErrorMessage(body: string) {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSSEBlockDelta(block: string) {
|
||||
const data = block
|
||||
.split(/\n/)
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.replace(/^data:\s?/, ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (!data || data === '[DONE]') return '';
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>;
|
||||
delta?: string;
|
||||
output_text?: string;
|
||||
};
|
||||
return parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? '';
|
||||
} catch {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
CatalogProvider,
|
||||
GatewayAccessRule,
|
||||
GatewayApiKey,
|
||||
GatewayTask,
|
||||
GatewayTenant,
|
||||
@@ -10,10 +11,12 @@ import type {
|
||||
PricingRule,
|
||||
PricingRuleSet,
|
||||
RateLimitWindow,
|
||||
RuntimePolicySet,
|
||||
UserGroup,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
|
||||
export interface ConsoleData {
|
||||
accessRules: GatewayAccessRule[];
|
||||
apiKeys: GatewayApiKey[];
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
models: PlatformModel[];
|
||||
@@ -22,6 +25,7 @@ export interface ConsoleData {
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
providers: CatalogProvider[];
|
||||
rateLimitWindows: RateLimitWindow[];
|
||||
runtimePolicySets: RuntimePolicySet[];
|
||||
taskResult: GatewayTask | null;
|
||||
tenants: GatewayTenant[];
|
||||
userGroups: UserGroup[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BaseModelCatalogItem, IntegrationPlatform, PlatformModel, RateLimi
|
||||
import { adminPages, apiDocPages, primaryModules, workspacePages } from '../navigation';
|
||||
import { DataPanel } from './DataPanel';
|
||||
import { ModuleList } from './ModuleList';
|
||||
import { baseModelTypeText } from '../pages/admin/platform-form';
|
||||
|
||||
export function Dashboard(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
@@ -66,7 +67,7 @@ export function Dashboard(props: {
|
||||
<DataPanel
|
||||
columns={['Provider', '名称', '状态', '优先级']}
|
||||
empty="暂无平台数据"
|
||||
rows={props.platforms.map((item) => [item.provider, item.name, item.status, String(item.priority)])}
|
||||
rows={props.platforms.map((item) => [item.provider, item.internalName || item.name, item.status, String(item.priority)])}
|
||||
title="平台"
|
||||
/>
|
||||
<DataPanel
|
||||
@@ -81,7 +82,7 @@ export function Dashboard(props: {
|
||||
<DataPanel
|
||||
columns={['Provider', '模型', '类型', '版本']}
|
||||
empty="暂无基准模型"
|
||||
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])}
|
||||
rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, baseModelTypeText(item), String(item.pricingVersion)])}
|
||||
title="基准模型库"
|
||||
/>
|
||||
<DataPanel
|
||||
|
||||
@@ -13,14 +13,18 @@ const buttonVariants = cva('shButton', {
|
||||
destructive: 'shButtonDestructive',
|
||||
},
|
||||
size: {
|
||||
default: 'shButtonDefaultSize',
|
||||
xs: 'shButtonXs',
|
||||
sm: 'shButtonSm',
|
||||
md: 'shButtonMd',
|
||||
default: 'shButtonMd',
|
||||
lg: 'shButtonLg',
|
||||
xl: 'shButtonXl',
|
||||
icon: 'shButtonIcon',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive h-3.5 w-3.5 shrink-0 rounded-[3px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-primary-foreground transition-none">
|
||||
<CheckIcon className="h-3 w-3" strokeWidth={2.5} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
cancelLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
confirmLabel?: string;
|
||||
confirmVariant?: 'default' | 'destructive';
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { onCancel, open } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onCancel();
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onCancel, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="confirmDialogBackdrop" role="presentation">
|
||||
<section className={cn('confirmDialog', props.className)} role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<div className="confirmDialogIcon">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div className="confirmDialogBody">
|
||||
<strong id="confirm-dialog-title">{props.title}</strong>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
{props.children}
|
||||
</div>
|
||||
<footer className="confirmDialogActions">
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
|
||||
{props.cancelLabel ?? '取消'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={props.confirmVariant ?? 'destructive'}
|
||||
size="sm"
|
||||
disabled={props.loading}
|
||||
onClick={() => void props.onConfirm()}
|
||||
>
|
||||
{props.confirmLabel ?? '确认'}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export function FormItem(props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
label: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={cn('shFormItem', props.className)}>
|
||||
<span className="shFormItemLabel">{props.label}</span>
|
||||
{props.children}
|
||||
{props.description ? <span className="shFormItemDescription">{props.description}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export * from './badge';
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './checkbox';
|
||||
export * from './confirm-dialog';
|
||||
export * from './dialog';
|
||||
export * from './form-item';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './message';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './table';
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => <input ref={ref} className={cn('shInput', className)} {...props} />,
|
||||
const inputVariants = cva('shInput', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'shControlXs',
|
||||
sm: 'shControlSm',
|
||||
md: 'shControlMd',
|
||||
lg: 'shControlLg',
|
||||
xl: 'shControlXl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
|
||||
VariantProps<typeof inputVariants> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, size, ...props }, ref) => <input ref={ref} className={cn(inputVariants({ size, className }))} {...props} />,
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
export type ScreenMessageVariant = 'info' | 'success' | 'error';
|
||||
|
||||
export interface ScreenMessageProps {
|
||||
className?: string;
|
||||
duration?: number;
|
||||
message: string;
|
||||
open?: boolean;
|
||||
variant?: ScreenMessageVariant;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const iconMap: Record<ScreenMessageVariant, React.ReactNode> = {
|
||||
error: <AlertCircle size={16} />,
|
||||
info: <Info size={16} />,
|
||||
success: <CheckCircle2 size={16} />,
|
||||
};
|
||||
|
||||
export function ScreenMessage(props: ScreenMessageProps) {
|
||||
const { duration = 3600, message, onClose, open = Boolean(message), variant = 'info' } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !message || duration <= 0 || !onClose) return undefined;
|
||||
const timer = window.setTimeout(onClose, duration);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [duration, message, onClose, open]);
|
||||
|
||||
if (!open || !message) return null;
|
||||
|
||||
return (
|
||||
<div className="screenMessageViewport" role="presentation">
|
||||
<div className={cn('screenMessage', `screenMessage-${variant}`, props.className)} role="alert">
|
||||
<span className="screenMessageIcon">{iconMap[variant]}</span>
|
||||
<span className="screenMessageText">{message}</span>
|
||||
{onClose && (
|
||||
<Button type="button" variant="ghost" size="icon" className="screenMessageClose" aria-label="关闭提示" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
|
||||
({ className, ...props }, ref) => <select ref={ref} className={cn('shInput', className)} {...props} />,
|
||||
const selectVariants = cva('shInput shSelect', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'shControlXs',
|
||||
sm: 'shControlSm',
|
||||
md: 'shControlMd',
|
||||
lg: 'shControlLg',
|
||||
xl: 'shControlXl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'>,
|
||||
VariantProps<typeof selectVariants> {}
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, size, ...props }, ref) => <select ref={ref} className={cn(selectVariants({ size, className }))} {...props} />,
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => <textarea ref={ref} className={cn('shTextarea', className)} {...props} />,
|
||||
const textareaVariants = cva('shTextarea', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'shTextareaXs',
|
||||
sm: 'shTextareaSm',
|
||||
md: 'shTextareaMd',
|
||||
lg: 'shTextareaLg',
|
||||
xl: 'shTextareaXl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
VariantProps<typeof textareaVariants> {}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, size, ...props }, ref) => <textarea ref={ref} className={cn(textareaVariants({ size, className }))} {...props} />,
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
createCatalogProvider,
|
||||
deleteBaseModel,
|
||||
deleteCatalogProvider,
|
||||
resetAllBaseModels,
|
||||
resetBaseModel,
|
||||
updateBaseModel,
|
||||
updateCatalogProvider,
|
||||
} from '../api';
|
||||
@@ -90,9 +92,44 @@ export function useCatalogOperations(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetBaseModelToDefault(baseModelId: string) {
|
||||
if (!input.token) throw new Error('请先登录后再维护基准模型');
|
||||
input.setCoreState('loading');
|
||||
input.setCoreMessage('');
|
||||
try {
|
||||
const model = await resetBaseModel(input.token, baseModelId);
|
||||
input.setBaseModels((current) => current.map((item) => (item.id === model.id ? model : item)));
|
||||
input.setCoreState('ready');
|
||||
input.setCoreMessage('基准模型已重置为系统默认。');
|
||||
} catch (err) {
|
||||
input.setCoreState('error');
|
||||
input.setCoreMessage(err instanceof Error ? err.message : '基准模型重置失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAllBaseModelsToDefault() {
|
||||
if (!input.token) throw new Error('请先登录后再维护基准模型');
|
||||
input.setCoreState('loading');
|
||||
input.setCoreMessage('');
|
||||
try {
|
||||
const response = await resetAllBaseModels(input.token);
|
||||
const resetModels = new Map(response.items.map((item) => [item.id, item]));
|
||||
input.setBaseModels((current) => current.map((item) => resetModels.get(item.id) ?? item));
|
||||
input.setCoreState('ready');
|
||||
input.setCoreMessage(`已重置 ${response.items.length} 个系统内置基准模型。`);
|
||||
} catch (err) {
|
||||
input.setCoreState('error');
|
||||
input.setCoreMessage(err instanceof Error ? err.message : '基准模型批量重置失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removeBaseModel,
|
||||
removeProvider,
|
||||
resetAllBaseModelsToDefault,
|
||||
resetBaseModelToDefault,
|
||||
saveBaseModel,
|
||||
saveProvider,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { createRuntimePolicySet, deleteRuntimePolicySet, updateRuntimePolicySet } from '../api';
|
||||
import type { LoadState } from '../types';
|
||||
|
||||
export function useRuntimePolicySetOperations(input: {
|
||||
setCoreMessage: Dispatch<SetStateAction<string>>;
|
||||
setCoreState: Dispatch<SetStateAction<LoadState>>;
|
||||
setRuntimePolicySets: Dispatch<SetStateAction<RuntimePolicySet[]>>;
|
||||
token: string;
|
||||
}) {
|
||||
async function saveRuntimePolicySet(payload: RuntimePolicySetUpsertRequest, policySetId?: string) {
|
||||
if (!input.token) throw new Error('请先登录后再维护运行策略');
|
||||
input.setCoreState('loading');
|
||||
input.setCoreMessage('');
|
||||
try {
|
||||
const policySet = policySetId
|
||||
? await updateRuntimePolicySet(input.token, policySetId, payload)
|
||||
: await createRuntimePolicySet(input.token, payload);
|
||||
input.setRuntimePolicySets((current) => [policySet, ...current.filter((item) => item.id !== policySet.id)]);
|
||||
input.setCoreState('ready');
|
||||
input.setCoreMessage(policySetId ? '运行策略已更新。' : '运行策略已新增。');
|
||||
} catch (err) {
|
||||
input.setCoreState('error');
|
||||
input.setCoreMessage(err instanceof Error ? err.message : '运行策略保存失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRuntimePolicySet(policySetId: string) {
|
||||
if (!input.token) throw new Error('请先登录后再维护运行策略');
|
||||
input.setCoreState('loading');
|
||||
input.setCoreMessage('');
|
||||
try {
|
||||
await deleteRuntimePolicySet(input.token, policySetId);
|
||||
input.setRuntimePolicySets((current) => current.filter((item) => item.id !== policySetId));
|
||||
input.setCoreState('ready');
|
||||
input.setCoreMessage('运行策略已删除。');
|
||||
} catch (err) {
|
||||
input.setCoreState('error');
|
||||
input.setCoreMessage(err instanceof Error ? err.message : '运行策略删除失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return { removeRuntimePolicySet, saveRuntimePolicySet };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'streamdown/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -5,6 +5,12 @@ export const primaryModules = [
|
||||
description: '服务状态、推荐模型、最近任务、用量摘要和快捷入口。',
|
||||
items: ['能力概览', '最近任务', '用量摘要'],
|
||||
},
|
||||
{
|
||||
title: '在线测试',
|
||||
path: '/playground',
|
||||
description: '大模型、图像生成和视频生成的在线测试工作台。',
|
||||
items: ['大模型', '图像生成', '视频生成'],
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
path: '/models',
|
||||
|
||||
+102
-143
@@ -1,51 +1,73 @@
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Boxes, Building2, Gauge, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
|
||||
import type { BaseModelUpsertRequest, CatalogProviderUpsertRequest, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import type {
|
||||
BaseModelUpsertRequest,
|
||||
CatalogProviderUpsertRequest,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRuleUpsertRequest,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayUserUpsertRequest,
|
||||
PricingRuleSetUpsertRequest,
|
||||
RuntimePolicySetUpsertRequest,
|
||||
UserGroupUpsertRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import type { ConsoleData, StatItem } from '../app-state';
|
||||
import { EntityTable } from '../components/EntityTable';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { StatGrid } from '../components/StatGrid';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Select, Tabs } from '../components/ui';
|
||||
import type { AdminSection, LoadState, PlatformForm, PlatformModelForm } from '../types';
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle, Tabs } from '../components/ui';
|
||||
import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types';
|
||||
import { AccessRulesPanel } from './admin/AccessRulesPanel';
|
||||
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
|
||||
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
|
||||
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
||||
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
||||
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
|
||||
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: '总览', icon: <Workflow size={15} /> },
|
||||
{ value: 'globalModels', label: 'Provider', icon: <Boxes size={15} /> },
|
||||
{ value: 'baseModels', label: '基准模型库', icon: <Boxes size={15} /> },
|
||||
{ value: 'pricing', label: '定价规则', icon: <Gauge size={15} /> },
|
||||
{ value: 'runtime', label: '运行策略', icon: <ShieldCheck size={15} /> },
|
||||
{ value: 'baseModels', label: '基准模型库', icon: <Boxes size={15} /> },
|
||||
{ value: 'platforms', label: '平台管理', icon: <ServerCog size={15} /> },
|
||||
{ value: 'tenants', label: '租户', icon: <Building2 size={15} /> },
|
||||
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
||||
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
||||
{ value: 'runtime', label: '运行限流', icon: <Gauge size={15} /> },
|
||||
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
|
||||
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
|
||||
|
||||
export function AdminPage(props: {
|
||||
data: ConsoleData;
|
||||
platformForm: PlatformForm;
|
||||
platformModelForm: PlatformModelForm;
|
||||
operationMessage: string;
|
||||
section: AdminSection;
|
||||
stats: StatItem[];
|
||||
state: LoadState;
|
||||
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
|
||||
onDeletePlatform: (platformId: string) => Promise<void>;
|
||||
onDeleteProvider: (providerId: string) => Promise<void>;
|
||||
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
|
||||
onPlatformFormChange: (value: PlatformForm) => void;
|
||||
onPlatformModelFormChange: (value: PlatformModelForm) => void;
|
||||
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
||||
onDeleteAccessRule: (ruleId: string) => Promise<void>;
|
||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
|
||||
onResetAllBaseModels: () => Promise<void>;
|
||||
onResetBaseModel: (baseModelId: string) => Promise<void>;
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
|
||||
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
|
||||
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
|
||||
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
||||
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
onSectionChange: (value: AdminSection) => void;
|
||||
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitPlatformModel: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<PageHeader eyebrow="Admin" title="管理工作台" description="全局模型、平台、租户用户和运行策略。" />
|
||||
<div className="subPageLayout">
|
||||
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
||||
<div className="subPageContent">
|
||||
@@ -63,12 +85,37 @@ export function AdminPage(props: {
|
||||
<BaseModelCatalogPanel
|
||||
baseModels={props.data.baseModels}
|
||||
message={props.operationMessage}
|
||||
pricingRuleSets={props.data.pricingRuleSets}
|
||||
providers={props.data.providers}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
onDeleteBaseModel={props.onDeleteBaseModel}
|
||||
onResetAllBaseModels={props.onResetAllBaseModels}
|
||||
onResetBaseModel={props.onResetBaseModel}
|
||||
onSaveBaseModel={props.onSaveBaseModel}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'runtime' && (
|
||||
<RuntimePoliciesPanel
|
||||
message={props.operationMessage}
|
||||
runtimePolicySets={props.data.runtimePolicySets}
|
||||
state={props.state}
|
||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'accessRules' && (
|
||||
<AccessRulesPanel
|
||||
accessRules={props.data.accessRules}
|
||||
baseModels={props.data.baseModels}
|
||||
message={props.operationMessage}
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
state={props.state}
|
||||
userGroups={props.data.userGroups}
|
||||
onBatchAccessRules={props.onBatchAccessRules}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'pricing' && (
|
||||
<PricingRulesPanel
|
||||
message={props.operationMessage}
|
||||
@@ -78,17 +125,52 @@ export function AdminPage(props: {
|
||||
onSavePricingRuleSet={props.onSavePricingRuleSet}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'platforms' && <PlatformsPanel {...props} />}
|
||||
{props.section === 'tenants' && <TenantsPanel data={props.data} />}
|
||||
{props.section === 'users' && <UsersPanel data={props.data} />}
|
||||
{props.section === 'userGroups' && <UserGroupsPanel data={props.data} />}
|
||||
{props.section === 'runtime' && <RuntimePanel data={props.data} />}
|
||||
{props.section === 'platforms' && (
|
||||
<PlatformManagementPanel
|
||||
baseModels={props.data.baseModels}
|
||||
message={props.operationMessage}
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
pricingRuleSets={props.data.pricingRuleSets}
|
||||
providers={props.data.providers}
|
||||
state={props.state}
|
||||
onDeletePlatform={props.onDeletePlatform}
|
||||
onSavePlatform={props.onSavePlatform}
|
||||
/>
|
||||
)}
|
||||
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
||||
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function identityPanelProps(props: {
|
||||
data: ConsoleData;
|
||||
operationMessage: string;
|
||||
state: LoadState;
|
||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
data: props.data,
|
||||
operationMessage: props.operationMessage,
|
||||
state: props.state,
|
||||
onDeleteTenant: props.onDeleteTenant,
|
||||
onDeleteUser: props.onDeleteUser,
|
||||
onDeleteUserGroup: props.onDeleteUserGroup,
|
||||
onSaveTenant: props.onSaveTenant,
|
||||
onSaveUser: props.onSaveUser,
|
||||
onSaveUserGroup: props.onSaveUserGroup,
|
||||
};
|
||||
}
|
||||
|
||||
function OverviewPanel(props: { data: ConsoleData; stats: StatItem[] }) {
|
||||
const enabledPlatforms = props.data.platforms.filter((item) => item.status === 'enabled');
|
||||
const chatModels = props.data.models.filter((item) => item.modelType === 'chat' && item.enabled);
|
||||
@@ -111,7 +193,7 @@ function OverviewPanel(props: { data: ConsoleData; stats: StatItem[] }) {
|
||||
<EntityTable
|
||||
columns={['Provider', '平台', '状态', '优先级']}
|
||||
empty="暂无平台"
|
||||
rows={props.data.platforms.slice(0, 6).map((item) => [item.provider, item.name, item.status, item.priority])}
|
||||
rows={props.data.platforms.slice(0, 6).map((item) => [item.provider, item.internalName || item.name, item.status, item.priority])}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -183,126 +265,3 @@ function ManagementCard(props: { detail: string; icon: ReactNode; label: string;
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformsPanel(props: {
|
||||
data: ConsoleData;
|
||||
platformForm: PlatformForm;
|
||||
platformModelForm: PlatformModelForm;
|
||||
state: LoadState;
|
||||
onPlatformFormChange: (value: PlatformForm) => void;
|
||||
onPlatformModelFormChange: (value: PlatformModelForm) => void;
|
||||
onSubmitPlatform: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onSubmitPlatformModel: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="contentGrid two">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>创建平台</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="formGrid" onSubmit={props.onSubmitPlatform}>
|
||||
<Label>Provider<Input value={props.platformForm.provider} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, provider: event.target.value })} /></Label>
|
||||
<Label>平台 Key<Input value={props.platformForm.platformKey} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, platformKey: event.target.value })} /></Label>
|
||||
<Label>名称<Input value={props.platformForm.name} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, name: event.target.value })} /></Label>
|
||||
<Label>Base URL<Input value={props.platformForm.baseUrl} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, baseUrl: event.target.value })} /></Label>
|
||||
<Label>
|
||||
定价规则
|
||||
<Select value={props.platformForm.pricingRuleSetId} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, pricingRuleSetId: event.target.value })}>
|
||||
<option value="">默认规则</option>
|
||||
{props.data.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>平台折扣率<Input value={props.platformForm.defaultDiscountFactor} onChange={(event) => props.onPlatformFormChange({ ...props.platformForm, defaultDiscountFactor: event.target.value })} /></Label>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>创建平台</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>绑定平台模型</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="formGrid" onSubmit={props.onSubmitPlatformModel}>
|
||||
<Label>
|
||||
平台
|
||||
<Select value={props.platformModelForm.platformId} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, platformId: event.target.value })}>
|
||||
<option value="">选择平台</option>
|
||||
{props.data.platforms.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
基准模型
|
||||
<Select value={props.platformModelForm.canonicalModelKey} onChange={(event) => updateBaseModel(event.target.value, props)}>
|
||||
<option value="">选择基准模型</option>
|
||||
{props.data.baseModels.map((item) => <option value={item.canonicalModelKey} key={item.id}>{item.canonicalModelKey}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>模型名<Input value={props.platformModelForm.modelName} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, modelName: event.target.value })} /></Label>
|
||||
<Label>别名<Input value={props.platformModelForm.modelAlias} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, modelAlias: event.target.value })} /></Label>
|
||||
<Label>
|
||||
定价规则
|
||||
<Select value={props.platformModelForm.pricingRuleSetId} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, pricingRuleSetId: event.target.value })}>
|
||||
<option value="">继承平台规则</option>
|
||||
{props.data.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>模型折扣率<Input value={props.platformModelForm.discountFactor} onChange={(event) => props.onPlatformModelFormChange({ ...props.platformModelForm, discountFactor: event.target.value })} /></Label>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>绑定模型</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="spanTwo">
|
||||
<CardHeader>
|
||||
<CardTitle>平台模型</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EntityTable
|
||||
columns={['模型', '类型', '平台', '状态']}
|
||||
empty="暂无平台模型"
|
||||
rows={props.data.models.map((item) => [item.modelName, item.modelType, item.platformName ?? item.provider ?? '-', item.enabled ? 'enabled' : 'disabled'])}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantsPanel(props: { data: ConsoleData }) {
|
||||
return <Card><CardHeader><CardTitle>租户管理</CardTitle></CardHeader><CardContent><EntityTable columns={['Key', '名称', '来源', '状态']} empty="暂无租户" rows={props.data.tenants.map((item) => [item.tenantKey, item.name, item.source, item.status])} /></CardContent></Card>;
|
||||
}
|
||||
|
||||
function UsersPanel(props: { data: ConsoleData }) {
|
||||
return <Card><CardHeader><CardTitle>用户管理</CardTitle></CardHeader><CardContent><EntityTable columns={['用户名', '租户', '来源', '状态']} empty="暂无用户" rows={props.data.users.map((item) => [item.username, item.tenantKey ?? '-', item.source, item.status])} /></CardContent></Card>;
|
||||
}
|
||||
|
||||
function UserGroupsPanel(props: { data: ConsoleData }) {
|
||||
return <Card><CardHeader><CardTitle>用户组策略</CardTitle></CardHeader><CardContent><EntityTable columns={['Key', '名称', '优先级', '状态']} empty="暂无用户组" rows={props.data.userGroups.map((item) => [item.groupKey, item.name, item.priority, item.status])} /></CardContent></Card>;
|
||||
}
|
||||
|
||||
function RuntimePanel(props: { data: ConsoleData }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>TPM / RPM / 并发窗口</CardTitle>
|
||||
<Badge variant="secondary">{props.data.rateLimitWindows.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EntityTable columns={['Scope', '指标', '使用', '限制']} empty="暂无限流窗口" rows={props.data.rateLimitWindows.map((item) => [item.scopeKey, item.metric, item.usedValue, item.limitValue])} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function updateBaseModel(value: string, props: Parameters<typeof PlatformsPanel>[0]) {
|
||||
const base = props.data.baseModels.find((item) => item.canonicalModelKey === value);
|
||||
props.onPlatformModelFormChange({
|
||||
...props.platformModelForm,
|
||||
canonicalModelKey: value,
|
||||
modelAlias: value,
|
||||
modelName: base?.providerModelName ?? '',
|
||||
modelType: base?.modelType ?? props.platformModelForm.modelType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Activity, Boxes, Image, Layers, Route, ServerCog, ShieldCheck, Video, Workflow } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardContent } from '../components/ui';
|
||||
import type { PageKey } from '../types';
|
||||
import type { PageKey, PlaygroundMode } from '../types';
|
||||
import { PlaygroundEntry, PublicWorksGallery } from './PlaygroundPage';
|
||||
|
||||
const coverage = [
|
||||
{ icon: <Activity size={18} />, label: '大模型对话', value: 'Chat / Responses', detail: '兼容 OpenAI 对话接口与 EasyAI 站内调用' },
|
||||
@@ -17,7 +18,10 @@ const advantages = [
|
||||
{ icon: <ServerCog size={18} />, title: 'Hybrid 接入', body: '既可独立闭环运行,也可以对接 server-main 的认证、上传和业务前端。' },
|
||||
];
|
||||
|
||||
export function HomePage(props: { onNavigate: (page: PageKey) => void }) {
|
||||
export function HomePage(props: {
|
||||
onNavigate: (page: PageKey) => void;
|
||||
onPlaygroundMode: (mode: PlaygroundMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="landingPage">
|
||||
<section className="releaseNotice">
|
||||
@@ -32,6 +36,7 @@ export function HomePage(props: { onNavigate: (page: PageKey) => void }) {
|
||||
<h1>一个面向多模型、多平台、多任务形态的 AI 网关中台</h1>
|
||||
<p>统一承接对话、生图、图像编辑和后续视频能力,让 server-main 通过稳定接口调用模型,让平台路由、失败重试、限流计费和任务恢复在网关侧闭环。</p>
|
||||
<div className="landingActions">
|
||||
<Button type="button" onClick={() => props.onNavigate('playground')}>开始在线测试</Button>
|
||||
<Button type="button" onClick={() => props.onNavigate('models')}>浏览模型</Button>
|
||||
<Button type="button" variant="outline" onClick={() => props.onNavigate('docs')}>查看 API 文档</Button>
|
||||
</div>
|
||||
@@ -39,6 +44,9 @@ export function HomePage(props: { onNavigate: (page: PageKey) => void }) {
|
||||
<GatewayPreview />
|
||||
</section>
|
||||
|
||||
<PlaygroundEntry onModeChange={props.onPlaygroundMode} />
|
||||
<PublicWorksGallery />
|
||||
|
||||
<section className="landingSection">
|
||||
<div className="landingSectionHeader">
|
||||
<p className="eyebrow">Model Coverage</p>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { ConsoleData } from '../app-state';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { Badge, Card, CardContent, Input } from '../components/ui';
|
||||
import { primaryBaseModelType, stableModelAlias } from './admin/platform-form';
|
||||
|
||||
type ModelListItem = {
|
||||
id: string;
|
||||
@@ -329,9 +330,9 @@ function modelFromBaseModel(model: BaseModelCatalogItem): ModelListItem {
|
||||
id: model.id,
|
||||
providerKey: model.providerKey,
|
||||
modelName: model.providerModelName,
|
||||
modelAlias: model.canonicalModelKey,
|
||||
modelType: model.modelType,
|
||||
displayName: model.displayName,
|
||||
modelAlias: stableModelAlias(model),
|
||||
modelType: primaryBaseModelType(model),
|
||||
displayName: stableModelAlias(model),
|
||||
capabilities: model.capabilities,
|
||||
pricingMode: 'inherit',
|
||||
billingConfig: model.baseBillingConfig,
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
GatewayAccessEffect,
|
||||
GatewayAccessRuleBatchRequest,
|
||||
GatewayAccessRuleResourceRequest,
|
||||
GatewayAccessResourceType,
|
||||
GatewayAccessRule,
|
||||
IntegrationPlatform,
|
||||
PlatformModel,
|
||||
UserGroup,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
||||
type ResourceKey = `${ResourceType}:${string}`;
|
||||
|
||||
type PlatformNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function AccessRulesPanel(props: {
|
||||
accessRules: GatewayAccessRule[];
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
message: string;
|
||||
platformModels: PlatformModel[];
|
||||
platforms: IntegrationPlatform[];
|
||||
state: LoadState;
|
||||
userGroups: UserGroup[];
|
||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||
}) {
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
|
||||
const [allowSearch, setAllowSearch] = useState('');
|
||||
const [denySearch, setDenySearch] = useState('');
|
||||
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
|
||||
const [localError, setLocalError] = useState('');
|
||||
|
||||
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
|
||||
const selectedGroupRules = useMemo(
|
||||
() => props.accessRules.filter((rule) => rule.subjectType === 'user_group' && rule.subjectId === selectedGroupId && rule.status === 'active'),
|
||||
[props.accessRules, selectedGroupId],
|
||||
);
|
||||
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(selectedGroupRules), [selectedGroupRules]);
|
||||
const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
|
||||
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroupId && props.userGroups[0]?.id) {
|
||||
setSelectedGroupId(props.userGroups[0].id);
|
||||
}
|
||||
}, [props.userGroups, selectedGroupId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
setDenyExpanded((current) => mergeExpanded(current, props.platforms));
|
||||
}, [props.platforms]);
|
||||
|
||||
async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) {
|
||||
const resourceKey = makeResourceKey(resourceType, resourceId);
|
||||
await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]);
|
||||
}
|
||||
|
||||
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
|
||||
const keys = [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
];
|
||||
await batchApply(effect, keys, enabled);
|
||||
}
|
||||
|
||||
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
|
||||
await batchApply(effect, visibleResourceKeys(nodes), true);
|
||||
}
|
||||
|
||||
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
|
||||
const keys = visibleResourceKeys(nodes);
|
||||
const upsertKeys: ResourceKey[] = [];
|
||||
const deleteKeys: ResourceKey[] = [];
|
||||
for (const key of keys) {
|
||||
const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`));
|
||||
if (checked) deleteKeys.push(key);
|
||||
else upsertKeys.push(key);
|
||||
}
|
||||
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
|
||||
}
|
||||
|
||||
async function clearEffect(effect: Effect) {
|
||||
const keys = selectedGroupRules
|
||||
.filter((rule) => rule.effect === effect)
|
||||
.map(resourceKeyFromRule)
|
||||
.filter((key): key is ResourceKey => Boolean(key));
|
||||
await applyPermissionBatch(effect, [], keys, '访问权限清空失败');
|
||||
}
|
||||
|
||||
async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) {
|
||||
await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys);
|
||||
}
|
||||
|
||||
async function applyPermissionBatch(
|
||||
effect: Effect,
|
||||
upsertKeys: ResourceKey[],
|
||||
deleteKeys: ResourceKey[],
|
||||
errorMessage = '访问权限更新失败',
|
||||
) {
|
||||
if (!selectedGroupId) return;
|
||||
const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key));
|
||||
const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey);
|
||||
if (upsertResources.length === 0 && deleteResources.length === 0) return;
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onBatchAccessRules({
|
||||
subjectType: 'user_group',
|
||||
subjectId: selectedGroupId,
|
||||
effect,
|
||||
upsertResources,
|
||||
deleteResources,
|
||||
});
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const allowSummary = countEffectRules(selectedGroupRules, 'allow');
|
||||
const denySummary = countEffectRules(selectedGroupRules, 'deny');
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>用户组访问权限</CardTitle>
|
||||
<p className="mutedText">数据仍写入统一权限规则表;这里按用户组维护专属使用和不允许使用的平台/模型。</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{props.accessRules.length} 条规则</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
<div className="accessGroupToolbar">
|
||||
<Label>
|
||||
用户组
|
||||
<Select size="sm" value={selectedGroupId} onChange={(event) => setSelectedGroupId(event.target.value)}>
|
||||
{props.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<div className="accessGroupHint">
|
||||
<ShieldCheck size={15} />
|
||||
<span>未配置规则时默认放行;拒绝规则优先于专属规则。</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedGroupId ? (
|
||||
<section className="accessPermissionGrid">
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="allow"
|
||||
expanded={allowExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={allowSearch}
|
||||
state={props.state}
|
||||
summary={allowSummary}
|
||||
title="专属使用(平台/模型)"
|
||||
tree={allowTree}
|
||||
onClear={() => void clearEffect('allow')}
|
||||
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setAllowExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('allow', allowTree)}
|
||||
onSearchChange={setAllowSearch}
|
||||
onSelectAll={() => void selectAll('allow', allowTree)}
|
||||
onToggleExpanded={(platformId) => setAllowExpanded(toggleSet(allowExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
<PermissionTreePanel
|
||||
emptyText="暂无可维护的平台模型"
|
||||
effect="deny"
|
||||
expanded={denyExpanded}
|
||||
rules={ruleByEffectAndResource}
|
||||
search={denySearch}
|
||||
state={props.state}
|
||||
summary={denySummary}
|
||||
title="不允许使用(平台/模型)"
|
||||
tree={denyTree}
|
||||
onClear={() => void clearEffect('deny')}
|
||||
onExpandAll={() => setDenyExpanded(new Set(platformTree.map((item) => item.id)))}
|
||||
onFoldAll={() => setDenyExpanded(new Set())}
|
||||
onReverse={() => void reverseVisible('deny', denyTree)}
|
||||
onSearchChange={setDenySearch}
|
||||
onSelectAll={() => void selectAll('deny', denyTree)}
|
||||
onToggleExpanded={(platformId) => setDenyExpanded(toggleSet(denyExpanded, platformId))}
|
||||
onTogglePlatformPermission={setPlatformPermission}
|
||||
onTogglePermission={setPermission}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<div className="emptyState">
|
||||
<strong>暂无用户组</strong>
|
||||
<span>请先创建用户组,再维护平台和模型权限。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreePanel(props: {
|
||||
effect: Effect;
|
||||
emptyText: string;
|
||||
expanded: Set<string>;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
search: string;
|
||||
state: LoadState;
|
||||
summary: { platforms: number; models: number };
|
||||
title: string;
|
||||
tree: PlatformNode[];
|
||||
onClear: () => void;
|
||||
onExpandAll: () => void;
|
||||
onFoldAll: () => void;
|
||||
onReverse: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="accessPermissionPanel">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.summary.platforms} 个平台 / {props.summary.models} 个模型</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="accessTreeToolbar">
|
||||
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}>展开</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}>折叠</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}>全选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}>反选</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}>清空</Button>
|
||||
</div>
|
||||
<div className="accessTreeBox">
|
||||
{props.tree.length ? props.tree.map((platform) => (
|
||||
<PlatformPermissionNode
|
||||
effect={props.effect}
|
||||
expanded={props.expanded.has(platform.id)}
|
||||
key={platform.id}
|
||||
platform={platform}
|
||||
rules={props.rules}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||
onTogglePermission={props.onTogglePermission}
|
||||
/>
|
||||
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPermissionNode(props: {
|
||||
effect: Effect;
|
||||
expanded: boolean;
|
||||
platform: PlatformNode;
|
||||
rules: Map<string, GatewayAccessRule>;
|
||||
onToggleExpanded: (platformId: string) => void;
|
||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`;
|
||||
const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
|
||||
const platformChecked = props.rules.has(platformRuleKey);
|
||||
const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false;
|
||||
return (
|
||||
<div className="accessTreeNode">
|
||||
<div className="accessTreeRow">
|
||||
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
|
||||
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={platformState}
|
||||
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
|
||||
/>
|
||||
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
|
||||
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
|
||||
</div>
|
||||
{props.expanded && props.platform.models.length > 0 && (
|
||||
<div className="accessTreeChildren">
|
||||
{props.platform.models.map((model) => {
|
||||
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
|
||||
return (
|
||||
<label className="accessTreeRow accessTreeModel" key={model.id}>
|
||||
<span />
|
||||
<Checkbox
|
||||
checked={props.rules.has(modelKey)}
|
||||
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
|
||||
/>
|
||||
<span title={model.subtitle || model.name}>{model.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
|
||||
const modelsByPlatform = new Map<string, PlatformModel[]>();
|
||||
for (const model of platformModels) {
|
||||
const current = modelsByPlatform.get(model.platformId) ?? [];
|
||||
current.push(model);
|
||||
modelsByPlatform.set(model.platformId, current);
|
||||
}
|
||||
return platforms.map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.internalName || platform.name,
|
||||
subtitle: `${platform.provider} / ${platform.platformKey}`,
|
||||
models: (modelsByPlatform.get(platform.id) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b)))
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: modelLabel(model),
|
||||
subtitle: `${model.modelType} / ${model.modelName}`,
|
||||
})),
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function filterTree(tree: PlatformNode[], keyword: string): PlatformNode[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return tree;
|
||||
return tree.flatMap((platform) => {
|
||||
const platformMatched = `${platform.name} ${platform.subtitle}`.toLowerCase().includes(normalized);
|
||||
const models = platformMatched
|
||||
? platform.models
|
||||
: platform.models.filter((model) => `${model.name} ${model.subtitle}`.toLowerCase().includes(normalized));
|
||||
return platformMatched || models.length ? [{ ...platform, models }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuleIndex(rules: GatewayAccessRule[]) {
|
||||
const index = new Map<string, GatewayAccessRule>();
|
||||
for (const rule of rules) {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') continue;
|
||||
if (rule.effect !== 'allow' && rule.effect !== 'deny') continue;
|
||||
index.set(`${rule.effect}:${makeResourceKey(rule.resourceType, rule.resourceId)}`, rule);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
|
||||
return rules.reduce((summary, rule) => {
|
||||
if (rule.effect !== effect) return summary;
|
||||
if (rule.resourceType === 'platform') summary.platforms += 1;
|
||||
if (rule.resourceType === 'platform_model') summary.models += 1;
|
||||
return summary;
|
||||
}, { platforms: 0, models: 0 });
|
||||
}
|
||||
|
||||
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
|
||||
return nodes.flatMap((platform) => [
|
||||
makeResourceKey('platform', platform.id),
|
||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
||||
]);
|
||||
}
|
||||
|
||||
function createPermissionResource(
|
||||
effect: Effect,
|
||||
resourceKey: ResourceKey,
|
||||
): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
priority: effect === 'deny' ? 10 : 100,
|
||||
minPermissionLevel: 0,
|
||||
conditions: {},
|
||||
metadata: { uiMode: 'user_group_permission_tree' },
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleResourceRequest {
|
||||
const [resourceType, resourceId] = splitResourceKey(resourceKey);
|
||||
return { resourceType, resourceId };
|
||||
}
|
||||
|
||||
function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined {
|
||||
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined;
|
||||
return makeResourceKey(rule.resourceType, rule.resourceId);
|
||||
}
|
||||
|
||||
function dedupeResourceKeys(keys: ResourceKey[]) {
|
||||
return Array.from(new Set(keys.filter(Boolean)));
|
||||
}
|
||||
|
||||
function mergeExpanded(current: Set<string>, platforms: IntegrationPlatform[]) {
|
||||
if (current.size > 0) return current;
|
||||
return new Set(platforms.map((item) => item.id));
|
||||
}
|
||||
|
||||
function toggleSet(set: Set<string>, value: string) {
|
||||
const next = new Set(set);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
}
|
||||
|
||||
function makeResourceKey(resourceType: ResourceType, resourceId: string): ResourceKey {
|
||||
return `${resourceType}:${resourceId}`;
|
||||
}
|
||||
|
||||
function splitResourceKey(key: ResourceKey): [ResourceType, string] {
|
||||
const [resourceType, ...rest] = key.split(':');
|
||||
return [resourceType as ResourceType, rest.join(':')];
|
||||
}
|
||||
|
||||
function modelLabel(model: PlatformModel) {
|
||||
return model.displayName || model.modelAlias || model.modelName;
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Brain, Image, ListChecks, Plus, Trash2, Video } from 'lucide-react';
|
||||
import { Button, Checkbox, Input, Select } from '../../components/ui';
|
||||
import {
|
||||
addCapabilityType,
|
||||
defaultCapabilityConfig,
|
||||
modelTypeGroup,
|
||||
modelTypeLabel,
|
||||
removeCapabilityType,
|
||||
updateCapabilityConfig,
|
||||
type CapabilityEditorState,
|
||||
} from './base-model-capabilities';
|
||||
|
||||
type FieldType =
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'text'
|
||||
| 'list'
|
||||
| 'numberList'
|
||||
| 'range'
|
||||
| 'ratioRange'
|
||||
| 'scopedList'
|
||||
| 'scopedNumberList'
|
||||
| 'scopedRange'
|
||||
| 'scopedNumber';
|
||||
|
||||
type FieldScope = 'mode' | 'resolution' | 'mixed';
|
||||
|
||||
type FieldDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
type: FieldType;
|
||||
scope?: FieldScope;
|
||||
};
|
||||
|
||||
type ValueOption = { label: string; value: string };
|
||||
|
||||
const textFields: FieldDefinition[] = [
|
||||
{ key: 'supportTool', label: '工具调用', hint: 'function calling / tools', type: 'boolean' },
|
||||
{ key: 'supportStructuredOutput', label: '结构化输出', hint: 'JSON Schema 等输出', type: 'boolean' },
|
||||
{ key: 'supportThinking', label: '思考能力', hint: '支持 thinking 参数', type: 'boolean' },
|
||||
{ key: 'supportThinkingModeSwitch', label: '思考开关', hint: '可按请求切换', type: 'boolean' },
|
||||
{ key: 'supportWebSearch', label: '联网搜索', type: 'boolean' },
|
||||
{ key: 'max_context_tokens', label: '上下文 Token', placeholder: '128000', type: 'number' },
|
||||
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
|
||||
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
|
||||
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
|
||||
{ key: 'thinkingEffortLevels', label: '思考强度', placeholder: 'minimal, low, medium, high', type: 'list' },
|
||||
];
|
||||
|
||||
const embeddingFields: FieldDefinition[] = [
|
||||
{ key: 'dimensions', label: '向量维度', placeholder: '1024, 768, 512', type: 'numberList' },
|
||||
];
|
||||
|
||||
const imageFields: FieldDefinition[] = [
|
||||
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
|
||||
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
|
||||
{ key: 'input_multiple_images', label: '多图输入', type: 'boolean' },
|
||||
{ key: 'input_max_images_count', label: '最多输入图片', placeholder: '10', type: 'number' },
|
||||
{ key: 'output_multiple_images', label: '多图输出', type: 'boolean' },
|
||||
{ key: 'output_max_images_count', label: '最多输出图片', placeholder: '4', type: 'number' },
|
||||
{ key: 'output_resolutions', label: '输出分辨率', placeholder: '1K, 2K, 4K', type: 'list' },
|
||||
{ key: 'output_max_size', label: '最大输出尺寸', placeholder: '16777216', type: 'number' },
|
||||
{ key: 'aspect_ratio_allowed', label: '支持宽高比', placeholder: '16:9, 1:1, 9:16', type: 'list' },
|
||||
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
|
||||
];
|
||||
|
||||
const videoFields: FieldDefinition[] = [
|
||||
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
|
||||
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
|
||||
{ key: 'output_resolutions', label: '输出分辨率', hint: '可直接配置,也可按首帧/首尾帧等模式配置', placeholder: '720p, 1080p', type: 'scopedList', scope: 'mode' },
|
||||
{ key: 'aspect_ratio_allowed', label: '支持宽高比', hint: '支持按分辨率配置可选宽高比', placeholder: '16:9, 9:16, 1:1', type: 'scopedList', scope: 'resolution' },
|
||||
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
|
||||
{ key: 'duration_range', label: '时长范围', hint: '支持按分辨率或模式配置不同范围', placeholder: '5 - 10', type: 'scopedRange', scope: 'mixed' },
|
||||
{ key: 'duration_options', label: '可选时长', hint: '与时长范围二选一;只允许这些秒数', placeholder: '5, 10', type: 'scopedNumberList', scope: 'mixed' },
|
||||
{ key: 'duration_step', label: '时长间隔', hint: '自定义时长按该秒数递增或取整', placeholder: '1', type: 'scopedNumber', scope: 'mixed' },
|
||||
{ key: 'input_audio', label: '音频输入', type: 'boolean' },
|
||||
{ key: 'output_audio', label: '输出音频', type: 'boolean' },
|
||||
{ key: 'output_bgm', label: '背景音乐', type: 'boolean' },
|
||||
{ key: 'input_first_frame', label: '首帧输入', type: 'boolean' },
|
||||
{ key: 'input_last_frame', label: '尾帧输入', type: 'boolean' },
|
||||
{ key: 'input_first_last_frame', label: '首尾帧模式', type: 'boolean' },
|
||||
{ key: 'input_reference_generate_single', label: '单参考图', type: 'boolean' },
|
||||
{ key: 'input_reference_generate_multiple', label: '多参考图', type: 'boolean' },
|
||||
{ key: 'input_smart_multi_frame', label: '智能多帧', type: 'boolean' },
|
||||
{ key: 'smart_multi_frame_range', label: '智能多帧数量', placeholder: '2 - 9', type: 'range' },
|
||||
{ key: 'smart_multi_frame_mode', label: '智能多帧模式', placeholder: 'native 或 stitch', type: 'text' },
|
||||
{ key: 'smart_multi_frame_duration_range', label: '智能多帧每段时长', placeholder: '2 - 7', type: 'range' },
|
||||
{ key: 'support_video_effect_template', label: '视频特效模板', type: 'boolean' },
|
||||
{ key: 'supported_modes', label: '支持模式', placeholder: 'text_to_video, image_reference', type: 'list' },
|
||||
{ key: 'max_videos', label: '最多视频', placeholder: '1', type: 'number' },
|
||||
{ key: 'max_images', label: '最多图片', placeholder: '4', type: 'number' },
|
||||
{ key: 'max_audios', label: '最多音频', placeholder: '1', type: 'number' },
|
||||
{ key: 'max_elements', label: '最多主体', placeholder: '4', type: 'number' },
|
||||
{ key: 'max_images_and_elements', label: '图片主体合计', placeholder: '4', type: 'number' },
|
||||
{ key: 'support_instruction_edit', label: '指令编辑', type: 'boolean' },
|
||||
];
|
||||
|
||||
const model3dFields: FieldDefinition[] = [
|
||||
{ key: 'support_texture', label: '纹理生成', type: 'boolean' },
|
||||
{ key: 'support_part_generation', label: '分部生成', type: 'boolean' },
|
||||
{ key: 'support_image_autofix', label: '图片自动优化', type: 'boolean' },
|
||||
{ key: 'support_quad', label: '四边面输出', type: 'boolean' },
|
||||
{ key: 'support_smart_low_poly', label: '智能低模', type: 'boolean' },
|
||||
{ key: 'geometry_quality_options', label: '几何质量', placeholder: 'standard, detailed', type: 'list' },
|
||||
{ key: 'max_face_limit', label: '三角面上限', placeholder: '20000', type: 'number' },
|
||||
{ key: 'max_face_limit_quad', label: '四边面上限', placeholder: '10000', type: 'number' },
|
||||
];
|
||||
|
||||
export function BaseModelCapabilityEditor(props: {
|
||||
modelType: string;
|
||||
typeOptions: string[];
|
||||
value: CapabilityEditorState;
|
||||
onChange: (value: CapabilityEditorState) => void;
|
||||
}) {
|
||||
const enabledTypes = props.value.types;
|
||||
const [activeType, setActiveType] = useState(enabledTypes[0] ?? props.modelType);
|
||||
const [pendingType, setPendingType] = useState('');
|
||||
const availableTypes = useMemo(
|
||||
() => Array.from(new Set([props.modelType, ...props.typeOptions].filter(Boolean))),
|
||||
[props.modelType, props.typeOptions],
|
||||
);
|
||||
const addableTypes = availableTypes.filter((type) => !enabledTypes.includes(type));
|
||||
const activeConfig = props.value.typeConfigs[activeType] ?? defaultCapabilityConfig(activeType);
|
||||
const fieldGroups = capabilityFieldGroups(activeType);
|
||||
const capabilitySummary = useMemo(
|
||||
() => summarizeEnabledCapabilities(enabledTypes, props.value.typeConfigs),
|
||||
[enabledTypes, props.value.typeConfigs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabledTypes.includes(activeType)) setActiveType(enabledTypes[0] ?? props.modelType);
|
||||
}, [activeType, enabledTypes, props.modelType]);
|
||||
|
||||
function addType() {
|
||||
const nextType = pendingType || addableTypes[0];
|
||||
if (!nextType) return;
|
||||
props.onChange(addCapabilityType(props.value, nextType));
|
||||
setActiveType(nextType);
|
||||
setPendingType('');
|
||||
}
|
||||
|
||||
function removeType(type: string) {
|
||||
const next = removeCapabilityType(props.value, type);
|
||||
props.onChange(next);
|
||||
setActiveType(next.types[0] ?? props.modelType);
|
||||
}
|
||||
|
||||
function patchConfig(key: string, value: unknown) {
|
||||
const nextConfig = { ...activeConfig };
|
||||
if (value === undefined) delete nextConfig[key];
|
||||
else nextConfig[key] = value;
|
||||
const exclusiveKey = exclusiveCapabilityFields[key];
|
||||
if (exclusiveKey && hasMeaningfulValue(value)) delete nextConfig[exclusiveKey];
|
||||
props.onChange(updateCapabilityConfig(props.value, activeType, sanitizeCapabilityConfig(nextConfig)));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="baseCapabilityEditor spanTwo bg-card text-foreground">
|
||||
<header>
|
||||
<div>
|
||||
<strong>模型能力</strong>
|
||||
<span>左侧维护已启用能力,右侧编辑该能力的默认配置。</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="capabilityWorkbench">
|
||||
<aside className="capabilitySidebar">
|
||||
<div className="capabilitySidebarTitle">
|
||||
<ListChecks size={15} />
|
||||
<strong>已启用能力</strong>
|
||||
</div>
|
||||
<div className="capabilityList">
|
||||
{enabledTypes.map((type) => (
|
||||
<button className="capabilityListItem" data-active={type === activeType} key={type} type="button" onClick={() => setActiveType(type)}>
|
||||
<span>
|
||||
<strong>{modelTypeLabel(type)}</strong>
|
||||
<small>{modelTypeGroup(type)} / {type}</small>
|
||||
</span>
|
||||
{enabledTypes.length > 1 && (
|
||||
<Trash2
|
||||
size={14}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeType(type);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="capabilityAddBox">
|
||||
<Select size="sm" value={pendingType} onChange={(event) => setPendingType(event.target.value)}>
|
||||
<option value="">{addableTypes.length ? '选择要添加的能力' : '没有可添加能力'}</option>
|
||||
{addableTypes.map((type) => <option key={type} value={type}>{modelTypeLabel(type)} / {type}</option>)}
|
||||
</Select>
|
||||
<Button size="sm" type="button" variant="outline" disabled={!addableTypes.length} onClick={addType}>
|
||||
<Plus size={14} />
|
||||
添加能力
|
||||
</Button>
|
||||
</div>
|
||||
<div className="capabilitySummary">
|
||||
<strong>能力总结</strong>
|
||||
<p>{capabilitySummary}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="capabilityFormPanel">
|
||||
{fieldGroups.length ? (
|
||||
fieldGroups.map((group) => (
|
||||
<CapabilityFormGroup key={group.title} icon={group.icon} title={group.title}>
|
||||
{group.fields.map((field) => (
|
||||
<CapabilityFormRow
|
||||
key={field.key}
|
||||
config={activeConfig}
|
||||
defaultConfig={defaultCapabilityConfig(activeType)}
|
||||
field={field}
|
||||
onChange={(value) => patchConfig(field.key, value)}
|
||||
/>
|
||||
))}
|
||||
</CapabilityFormGroup>
|
||||
))
|
||||
) : (
|
||||
<div className="capabilityPreserveNote">
|
||||
<strong>当前能力暂无通用表单</strong>
|
||||
<span>仍会保存该能力条目,并保留原始能力字段;后续可按平台专属能力继续扩展。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CapabilityFormGroup icon={<Brain size={15} />} title="保留字段">
|
||||
<div className="capabilityPreserveNote">
|
||||
<strong>{preservedConfigCount(activeConfig, fieldGroups)} 个未展示字段</strong>
|
||||
<span>这些字段保存时会继续保留在 {activeType} 能力配置内。</span>
|
||||
</div>
|
||||
</CapabilityFormGroup>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityFormGroup(props: { children: ReactNode; icon: ReactNode; title: string }) {
|
||||
return (
|
||||
<section className="capabilityFormGroup">
|
||||
<header>
|
||||
{props.icon}
|
||||
<strong>{props.title}</strong>
|
||||
</header>
|
||||
<div className="capabilityFormRows">{props.children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityFormRow(props: {
|
||||
config: Record<string, unknown>;
|
||||
defaultConfig: Record<string, unknown>;
|
||||
field: FieldDefinition;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const value = props.config[props.field.key];
|
||||
return (
|
||||
<div className="capabilityFormRow">
|
||||
<div>
|
||||
<strong>{props.field.label}</strong>
|
||||
{props.field.hint && <small title={props.field.hint}>{props.field.hint}</small>}
|
||||
</div>
|
||||
<FieldControl
|
||||
config={props.config}
|
||||
defaultValue={props.defaultConfig[props.field.key]}
|
||||
field={props.field}
|
||||
value={value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldControl(props: {
|
||||
config: Record<string, unknown>;
|
||||
defaultValue: unknown;
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
if (props.field.type === 'boolean') {
|
||||
return <BooleanFieldControl defaultText={booleanDefaultText(props.field, props.defaultValue)} value={props.value} onChange={props.onChange} />;
|
||||
}
|
||||
if (props.field.type === 'range') {
|
||||
const range = Array.isArray(props.value) ? props.value : [];
|
||||
return (
|
||||
<div className="capabilityRange">
|
||||
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
|
||||
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.field.type === 'ratioRange') {
|
||||
const range = Array.isArray(props.value) ? props.value : [];
|
||||
return (
|
||||
<div className="capabilityRange">
|
||||
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[0])} placeholder="最小宽/高" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
|
||||
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[1])} placeholder="最大宽/高" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.field.type === 'number') {
|
||||
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
|
||||
}
|
||||
if (isScopedField(props.field)) {
|
||||
return <ScopedFieldControl field={props.field} value={props.value} config={props.config} onChange={props.onChange} />;
|
||||
}
|
||||
if (props.field.type === 'text') {
|
||||
return <Input size="sm" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(event.target.value)} />;
|
||||
}
|
||||
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
|
||||
}
|
||||
|
||||
function BooleanFieldControl(props: {
|
||||
defaultText: string;
|
||||
onChange: (value: unknown) => void;
|
||||
value: unknown;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
className="capabilityBooleanSelect"
|
||||
size="sm"
|
||||
value={booleanSelectValue(props.value)}
|
||||
onChange={(event) => props.onChange(booleanValueFromSelect(event.target.value))}
|
||||
>
|
||||
<option value="unset">未设置({props.defaultText})</option>
|
||||
<option value="true">支持</option>
|
||||
<option value="false">不支持</option>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopedFieldControl(props: {
|
||||
config: Record<string, unknown>;
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const grouped = isPlainRecord(props.value);
|
||||
const directValue = grouped ? defaultDirectValue(props.field) : props.value;
|
||||
const scopeOptions = scopeOptionsForField(props.field, props.config, props.value);
|
||||
const entries = grouped ? Object.entries(props.value as Record<string, unknown>) : [];
|
||||
const canGroup = scopeOptions.length > 0 || entries.length > 0;
|
||||
|
||||
function switchMode(nextGrouped: boolean) {
|
||||
if (nextGrouped === grouped) return;
|
||||
if (!nextGrouped) {
|
||||
props.onChange(entries[0]?.[1] ?? defaultDirectValue(props.field));
|
||||
return;
|
||||
}
|
||||
const firstKey = firstAvailableScopeKey(scopeOptions, []);
|
||||
props.onChange({ [firstKey]: normalizeScopedValue(props.field, props.value) });
|
||||
}
|
||||
|
||||
function setEntryKey(oldKey: string, nextKey: string) {
|
||||
if (!nextKey || !grouped) return;
|
||||
const next = { ...(props.value as Record<string, unknown>) };
|
||||
const current = next[oldKey];
|
||||
delete next[oldKey];
|
||||
next[nextKey] = current;
|
||||
props.onChange(next);
|
||||
}
|
||||
|
||||
function setEntryValue(key: string, nextValue: unknown) {
|
||||
props.onChange({ ...(props.value as Record<string, unknown>), [key]: nextValue });
|
||||
}
|
||||
|
||||
function removeEntry(key: string) {
|
||||
const next = { ...(props.value as Record<string, unknown>) };
|
||||
delete next[key];
|
||||
props.onChange(Object.keys(next).length ? next : defaultDirectValue(props.field));
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
const used = entries.map(([key]) => key);
|
||||
const nextKey = firstAvailableScopeKey(scopeOptions, used);
|
||||
const current = grouped ? (props.value as Record<string, unknown>) : {};
|
||||
props.onChange({ ...current, [nextKey]: defaultDirectValue(props.field) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="capabilityScopedControl">
|
||||
<div className="capabilitySegmented" role="tablist" aria-label={`${props.field.label}配置方式`}>
|
||||
<button type="button" data-active={!grouped} onClick={() => switchMode(false)}>统一配置</button>
|
||||
<button type="button" disabled={!canGroup} data-active={grouped} onClick={() => switchMode(true)}>条件分组</button>
|
||||
</div>
|
||||
|
||||
{!grouped ? (
|
||||
<ScopedValueInput config={props.config} field={props.field} value={directValue} placeholder={props.field.placeholder} onChange={props.onChange} />
|
||||
) : (
|
||||
<div className="capabilityScopedRows">
|
||||
{entries.map(([key, value]) => (
|
||||
<div className="capabilityScopedRow" key={key}>
|
||||
<Select size="sm" value={key} onChange={(event) => setEntryKey(key, event.target.value)}>
|
||||
{ensureScopeOption(scopeOptions, key).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
<ScopedValueInput config={props.config} field={props.field} value={value} placeholder={props.field.placeholder} onChange={(nextValue) => setEntryValue(key, nextValue)} />
|
||||
<Button type="button" size="icon" variant="ghost" aria-label="删除条件" onClick={() => removeEntry(key)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="outline" onClick={addEntry}>
|
||||
<Plus size={14} />
|
||||
添加条件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopedValueInput(props: {
|
||||
config: Record<string, unknown>;
|
||||
field: FieldDefinition;
|
||||
onChange: (value: unknown) => void;
|
||||
placeholder?: string;
|
||||
value: unknown;
|
||||
}) {
|
||||
if (props.field.type === 'scopedRange') {
|
||||
const range = Array.isArray(props.value) ? props.value : [];
|
||||
return (
|
||||
<div className="capabilityRange">
|
||||
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
|
||||
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (props.field.type === 'scopedNumber') {
|
||||
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
|
||||
}
|
||||
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
|
||||
}
|
||||
|
||||
function MultiValueControl(props: {
|
||||
config: Record<string, unknown>;
|
||||
field: FieldDefinition;
|
||||
onChange: (value: unknown) => void;
|
||||
value: unknown;
|
||||
}) {
|
||||
const [customValue, setCustomValue] = useState('');
|
||||
const selected = valueArray(props.value);
|
||||
const options = multiOptionsForField(props.field, props.config, props.value);
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
function emit(nextValues: string[]) {
|
||||
const values = uniqueStrings(nextValues);
|
||||
props.onChange(isNumberListField(props.field) ? values.map(Number).filter(Number.isFinite) : values);
|
||||
}
|
||||
|
||||
function toggle(value: string) {
|
||||
emit(selectedSet.has(value) ? selected.filter((item) => item !== value) : [...selected, value]);
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
const values = customValue.split(/[,,]/).map((item) => item.trim()).filter(Boolean);
|
||||
if (!values.length) return;
|
||||
emit([...selected, ...values]);
|
||||
setCustomValue('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="capabilityMultiValue">
|
||||
<div className="capabilityMultiOptions">
|
||||
{options.map((option) => (
|
||||
<label key={option.value} className="capabilityCheckboxOption">
|
||||
<Checkbox
|
||||
checked={selectedSet.has(option.value)}
|
||||
onCheckedChange={() => toggle(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="capabilityCustomValue">
|
||||
<Input
|
||||
size="sm"
|
||||
value={customValue}
|
||||
placeholder={props.field.placeholder ? `自定义:${props.field.placeholder}` : '自定义值'}
|
||||
onChange={(event) => setCustomValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button className="capabilityAddValueButton" type="button" size="sm" variant="outline" onClick={addCustom}>添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScopeOption = { label: string; requires?: string; value: string };
|
||||
|
||||
const modeScopeOptions: ScopeOption[] = [
|
||||
{ value: 'input_first_frame', label: '首帧模式 (input_first_frame)', requires: 'input_first_frame' },
|
||||
{ value: 'input_first_last_frame', label: '首尾帧模式 (input_first_last_frame)', requires: 'input_first_last_frame' },
|
||||
{ value: 'input_last_frame', label: '尾帧模式 (input_last_frame)', requires: 'input_last_frame' },
|
||||
{ value: 'input_smart_multi_frame', label: '智能多帧 (input_smart_multi_frame)', requires: 'input_smart_multi_frame' },
|
||||
];
|
||||
|
||||
const videoResolutionOptions = ['480p', '720p', '1080p', '1440p', '2160p'];
|
||||
const imageResolutionOptions = ['1K', '2K', '3K', '4K', '8K'];
|
||||
const videoAspectRatioOptions = ['adaptive', '16:9', '9:16', '1:1', '4:3', '3:4'];
|
||||
const imageAspectRatioOptions = [
|
||||
'adaptive',
|
||||
'1:1',
|
||||
'16:9',
|
||||
'9:16',
|
||||
'4:3',
|
||||
'3:4',
|
||||
'3:2',
|
||||
'2:3',
|
||||
'5:4',
|
||||
'4:5',
|
||||
'5:3',
|
||||
'3:5',
|
||||
'21:9',
|
||||
'9:21',
|
||||
'2:1',
|
||||
'1:2',
|
||||
'4:1',
|
||||
'1:4',
|
||||
'8:1',
|
||||
'1:8',
|
||||
'7:4',
|
||||
'4:7',
|
||||
];
|
||||
const thinkingEffortOptions = ['minimal', 'low', 'medium', 'high'];
|
||||
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
|
||||
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
|
||||
const exclusiveCapabilityFields: Record<string, string> = {
|
||||
duration_range: 'duration_options',
|
||||
duration_options: 'duration_range',
|
||||
};
|
||||
|
||||
function isScopedField(field: FieldDefinition) {
|
||||
return field.type === 'scopedList' || field.type === 'scopedNumberList' || field.type === 'scopedRange' || field.type === 'scopedNumber';
|
||||
}
|
||||
|
||||
function scopeOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ScopeOption[] {
|
||||
const options: ScopeOption[] = [];
|
||||
if (field.scope === 'mode' || field.scope === 'mixed') {
|
||||
options.push(...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true));
|
||||
flattenStringValues(config.supported_modes).forEach((mode) => options.push({ value: mode, label: `${friendlyScopeLabel(mode)} (${mode})` }));
|
||||
}
|
||||
if (field.scope === 'resolution' || field.scope === 'mixed') {
|
||||
[...flattenStringValues(config.output_resolutions), ...videoResolutionOptions].forEach((resolution) => {
|
||||
options.push({ value: resolution, label: `${friendlyScopeLabel(resolution)} (${resolution})` });
|
||||
});
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
Object.keys(value).forEach((key) => options.push({ value: key, label: `${friendlyScopeLabel(key)} (${key})` }));
|
||||
}
|
||||
return dedupeScopeOptions(options);
|
||||
}
|
||||
|
||||
function ensureScopeOption(options: ScopeOption[], value: string) {
|
||||
if (!value || options.some((option) => option.value === value)) return options;
|
||||
return [...options, { value, label: `${friendlyScopeLabel(value)} (${value})` }];
|
||||
}
|
||||
|
||||
function firstAvailableScopeKey(options: ScopeOption[], used: string[]) {
|
||||
return options.find((option) => !used.includes(option.value))?.value ?? `condition_${used.length + 1}`;
|
||||
}
|
||||
|
||||
function sanitizeCapabilityConfig(config: Record<string, unknown>) {
|
||||
const supportedModeKeys = new Set([
|
||||
...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true).map((option) => option.value),
|
||||
...flattenStringValues(config.supported_modes),
|
||||
]);
|
||||
const keepScopedModeKey = (key: string) => !isKnownModeScopeKey(key) || supportedModeKeys.has(key);
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => {
|
||||
if (!isScopedConfigKey(key) || !isPlainRecord(value)) return [key, value];
|
||||
const filtered = Object.fromEntries(Object.entries(value).filter(([scopeKey]) => keepScopedModeKey(scopeKey)));
|
||||
if (Object.keys(filtered).length > 0) return [key, filtered];
|
||||
return [key, defaultDirectValueForConfigKey(key)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isScopedConfigKey(key: string) {
|
||||
return key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'duration_range' || key === 'duration_options' || key === 'duration_step';
|
||||
}
|
||||
|
||||
function isKnownModeScopeKey(key: string) {
|
||||
return modeScopeOptions.some((option) => option.value === key) || ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'].includes(key);
|
||||
}
|
||||
|
||||
function defaultDirectValueForConfigKey(key: string) {
|
||||
if (key === 'duration_range') return [0, 0];
|
||||
if (key === 'duration_step') return 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
function multiOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ValueOption[] {
|
||||
let options: string[] = [];
|
||||
if (field.key === 'output_resolutions') options = field.scope ? videoResolutionOptions : imageResolutionOptions;
|
||||
if (field.key === 'aspect_ratio_allowed') options = field.scope ? videoAspectRatioOptions : imageAspectRatioOptions;
|
||||
if (field.key === 'supported_modes') options = omniVideoModeOptions;
|
||||
if (field.key === 'thinkingEffortLevels') options = thinkingEffortOptions;
|
||||
if (field.key === 'duration_options') options = durationOptionValues;
|
||||
if (field.key === 'dimensions') options = ['3072', '1536', '1024', '768', '512', '256'];
|
||||
if (field.key === 'geometry_quality_options') options = ['standard', 'detailed'];
|
||||
options = orderedOptionValues(options, [...valueArray(value), ...flattenStringValues(config[field.key])], field);
|
||||
return options.map((item) => ({ value: item, label: friendlyOptionLabel(field, item) }));
|
||||
}
|
||||
|
||||
function orderedOptionValues(baseOptions: string[], extraOptions: string[], field: FieldDefinition) {
|
||||
const base = uniqueStrings(baseOptions);
|
||||
const baseSet = new Set(base);
|
||||
const extras = uniqueStrings(extraOptions).filter((item) => !baseSet.has(item));
|
||||
if (field.key === 'duration_options') {
|
||||
return uniqueStrings([...base, ...extras]).sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
const sortedExtras = isNumberListField(field)
|
||||
? extras.sort((a, b) => Number(a) - Number(b))
|
||||
: extras.sort((a, b) => a.localeCompare(b));
|
||||
return [...base, ...sortedExtras];
|
||||
}
|
||||
|
||||
function friendlyOptionLabel(field: FieldDefinition, value: string) {
|
||||
if (field.key === 'duration_options') return `${value}秒`;
|
||||
if (field.key === 'supported_modes') return friendlyScopeLabel(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function valueArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
||||
if (value === undefined || value === null || value === '') return [];
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function isNumberListField(field: FieldDefinition) {
|
||||
return field.type === 'numberList' || field.type === 'scopedNumberList';
|
||||
}
|
||||
|
||||
function friendlyScopeLabel(key: string) {
|
||||
const labels: Record<string, string> = {
|
||||
input_first_frame: '首帧模式',
|
||||
input_first_last_frame: '首尾帧模式',
|
||||
input_last_frame: '尾帧模式',
|
||||
input_smart_multi_frame: '智能多帧',
|
||||
text_to_video: '文生视频',
|
||||
image_reference: '图片参考',
|
||||
element_reference: '主体参考',
|
||||
first_last_frame: '首尾帧',
|
||||
video_reference: '视频参考',
|
||||
video_edit: '视频编辑',
|
||||
multi_shot: '多镜头',
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
|
||||
function dedupeScopeOptions(options: ScopeOption[]) {
|
||||
const seen = new Set<string>();
|
||||
return options.filter((option) => {
|
||||
if (!option.value || seen.has(option.value)) return false;
|
||||
seen.add(option.value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function flattenStringValues(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
||||
if (!isPlainRecord(value)) return [];
|
||||
return Object.values(value).flatMap((item) => Array.isArray(item) ? item.map(String).filter(Boolean) : []);
|
||||
}
|
||||
|
||||
function defaultDirectValue(field: FieldDefinition) {
|
||||
if (field.type === 'scopedRange') return [0, 0];
|
||||
if (field.type === 'scopedNumber') return 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeScopedValue(field: FieldDefinition, value: unknown) {
|
||||
if (isPlainRecord(value)) return Object.values(value)[0] ?? defaultDirectValue(field);
|
||||
if (field.type === 'scopedRange') return Array.isArray(value) ? value : defaultDirectValue(field);
|
||||
if (field.type === 'scopedNumber') return typeof value === 'number' || typeof value === 'string' ? numberValue(value) : 0;
|
||||
return Array.isArray(value) ? value : defaultDirectValue(field);
|
||||
}
|
||||
|
||||
function hasMeaningfulValue(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (isPlainRecord(value)) return Object.keys(value).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function capabilityFieldGroups(type: string): Array<{ title: string; icon: ReactNode; fields: FieldDefinition[] }> {
|
||||
if (isTextType(type)) return [{ title: '文本默认能力', icon: <Brain size={15} />, fields: textFields }];
|
||||
if (type === 'text_embedding') return [{ title: '向量默认能力', icon: <Brain size={15} />, fields: embeddingFields }];
|
||||
if (isImageType(type)) return [{ title: '图像默认能力', icon: <Image size={15} />, fields: imageFieldsFor(type) }];
|
||||
if (isVideoType(type)) return [{ title: '视频默认能力', icon: <Video size={15} />, fields: videoFieldsFor(type) }];
|
||||
if (isModel3dType(type)) return [{ title: '3D 默认能力', icon: <Brain size={15} />, fields: model3dFields }];
|
||||
return [];
|
||||
}
|
||||
|
||||
function imageFieldsFor(type: string) {
|
||||
if (type === 'image_edit') return imageFields;
|
||||
return imageFields.filter((field) => !field.key.startsWith('input_'));
|
||||
}
|
||||
|
||||
function videoFieldsFor(type: string) {
|
||||
if (type === 'video_generate') return videoFields.filter((field) => !field.key.startsWith('input_') && field.key !== 'supported_modes');
|
||||
if (type === 'image_to_video') return videoFields.filter((field) => field.key !== 'supported_modes' && field.key !== 'max_audios');
|
||||
if (type === 'video_edit') return videoFields.filter((field) => !field.key.includes('reference_generate') && field.key !== 'supported_modes');
|
||||
return videoFields;
|
||||
}
|
||||
|
||||
function preservedConfigCount(config: Record<string, unknown>, groups: Array<{ fields: FieldDefinition[] }>) {
|
||||
const shown = new Set(groups.flatMap((group) => group.fields.map((field) => field.key)));
|
||||
return Object.keys(config).filter((key) => !shown.has(key)).length;
|
||||
}
|
||||
|
||||
function summarizeEnabledCapabilities(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
|
||||
if (!types.length) return '暂无启用能力,添加能力后会在这里显示模型支持范围。';
|
||||
const groups = uniqueStrings(types.map(modelTypeGroup));
|
||||
const names = types.map(modelTypeLabel);
|
||||
const nameText = names.length > 3 ? `${names.slice(0, 3).join('、')}等 ${names.length} 项能力` : names.join('、');
|
||||
const highlights = capabilitySummaryHighlights(types, typeConfigs);
|
||||
const base = `已启用 ${types.length} 项能力,覆盖${groups.join('、')}。包含 ${nameText}`;
|
||||
return highlights.length ? `${base};${highlights.join(',')}。` : `${base}。`;
|
||||
}
|
||||
|
||||
function capabilitySummaryHighlights(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
|
||||
const highlights: string[] = [];
|
||||
const textLimits = uniqueStrings(types.flatMap((type) => stringValue(typeConfigs[type]?.max_context_tokens ? typeConfigs[type].max_context_tokens : '').split(',').filter(Boolean)));
|
||||
if (textLimits.length) highlights.push(`上下文 ${textLimits[0]} Token`);
|
||||
|
||||
const resolutions = uniqueStrings(types.flatMap((type) => flattenStringValues(typeConfigs[type]?.output_resolutions)));
|
||||
if (resolutions.length) highlights.push(`输出分辨率 ${resolutions.slice(0, 4).join('/')}`);
|
||||
|
||||
const aspectRatioRanges = uniqueStrings(types.flatMap((type) => aspectRatioRangeSummaryValues(typeConfigs[type])));
|
||||
if (aspectRatioRanges.length) highlights.push(`宽高比 ${aspectRatioRanges.slice(0, 2).join('/')}`);
|
||||
|
||||
const durations = uniqueStrings(types.flatMap((type) => durationSummaryValues(typeConfigs[type])));
|
||||
if (durations.length) highlights.push(`时长 ${durations.slice(0, 3).join('/')}`);
|
||||
|
||||
const booleanLabels = types.flatMap((type) => enabledBooleanLabels(typeConfigs[type]));
|
||||
if (booleanLabels.length) highlights.push(`支持${uniqueStrings(booleanLabels).slice(0, 3).join('、')}`);
|
||||
|
||||
return highlights.slice(0, 4);
|
||||
}
|
||||
|
||||
function aspectRatioRangeSummaryValues(config?: Record<string, unknown>) {
|
||||
const range = config?.aspect_ratio_range;
|
||||
if (!Array.isArray(range) || range.length < 2) return [];
|
||||
return [`${range[0]}-${range[1]}`];
|
||||
}
|
||||
|
||||
function durationSummaryValues(config?: Record<string, unknown>) {
|
||||
if (!config) return [];
|
||||
const options = flattenStringValues(config.duration_options).map((item) => `${item}秒`);
|
||||
if (options.length) return options;
|
||||
const range = config.duration_range;
|
||||
if (Array.isArray(range) && range.length >= 2) return [`${range[0]}-${range[1]}秒`];
|
||||
if (!isPlainRecord(range)) return [];
|
||||
return Object.values(range).flatMap((item) => Array.isArray(item) && item.length >= 2 ? [`${item[0]}-${item[1]}秒`] : []);
|
||||
}
|
||||
|
||||
function enabledBooleanLabels(config?: Record<string, unknown>) {
|
||||
if (!config) return [];
|
||||
const labels: Record<string, string> = {
|
||||
supportTool: '工具调用',
|
||||
supportStructuredOutput: '结构化输出',
|
||||
supportThinking: '思考能力',
|
||||
supportWebSearch: '联网搜索',
|
||||
support_base64_input: 'Base64 输入',
|
||||
support_url_input: 'URL 输入',
|
||||
input_multiple_images: '多图输入',
|
||||
output_multiple_images: '多图输出',
|
||||
input_audio: '音频输入',
|
||||
output_audio: '输出音频',
|
||||
output_bgm: '背景音乐',
|
||||
input_first_frame: '首帧输入',
|
||||
input_first_last_frame: '首尾帧模式',
|
||||
input_reference_generate_single: '单参考图',
|
||||
input_reference_generate_multiple: '多参考图',
|
||||
support_video_effect_template: '视频特效模板',
|
||||
};
|
||||
return Object.entries(labels).filter(([key]) => config[key] === true).map(([, label]) => label);
|
||||
}
|
||||
|
||||
function isTextType(type: string) {
|
||||
return ['text_generate', 'image_analysis', 'video_understanding', 'audio_understanding', 'omni', 'tools_call'].includes(type);
|
||||
}
|
||||
|
||||
function isImageType(type: string) {
|
||||
return type === 'image_generate' || type === 'image_edit';
|
||||
}
|
||||
|
||||
function isVideoType(type: string) {
|
||||
return ['video_generate', 'image_to_video', 'video_edit', 'omni_video'].includes(type);
|
||||
}
|
||||
|
||||
function isModel3dType(type: string) {
|
||||
return ['text_to_model', 'image_to_model', 'multiview_to_model', 'mesh_edit'].includes(type);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function booleanSelectValue(value: unknown) {
|
||||
if (value === true) return 'true';
|
||||
if (value === false) return 'false';
|
||||
return 'unset';
|
||||
}
|
||||
|
||||
function booleanDefaultText(field: FieldDefinition, defaultValue: unknown) {
|
||||
if (field.key === 'support_base64_input' || field.key === 'support_url_input') return '默认自动判断';
|
||||
return defaultValue === true ? '默认支持' : '默认不支持';
|
||||
}
|
||||
|
||||
function booleanValueFromSelect(value: string) {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]) {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && !Array.isArray(value) && typeof value === 'object';
|
||||
}
|
||||
@@ -4,91 +4,109 @@ import type {
|
||||
BaseModelCatalogItem,
|
||||
BaseModelUpsertRequest,
|
||||
CatalogProvider,
|
||||
PricingRuleSet,
|
||||
RuntimePolicySet,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select, Textarea } from '../../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
import { BaseModelCapabilityEditor } from './BaseModelCapabilityEditor';
|
||||
import {
|
||||
addCapabilityType,
|
||||
capabilitiesFromForm,
|
||||
capabilitiesToForm,
|
||||
createCapabilityEditorState,
|
||||
platformModelTypeDefinitions,
|
||||
removeCapabilityType,
|
||||
syncPrimaryCapability,
|
||||
type CapabilityEditorState,
|
||||
} from './base-model-capabilities';
|
||||
import { ModelCatalogCard } from './ModelCatalogCard';
|
||||
|
||||
type ModelForm = {
|
||||
providerKey: string;
|
||||
canonicalModelKey: string;
|
||||
providerModelName: string;
|
||||
modelType: string;
|
||||
displayName: string;
|
||||
modelAlias: string;
|
||||
status: string;
|
||||
pricingRuleSetId: string;
|
||||
runtimePolicySetId: string;
|
||||
runtimePolicyOverrideJson: string;
|
||||
pricingVersion: string;
|
||||
capabilitiesJson: string;
|
||||
capabilities: CapabilityEditorState;
|
||||
billingJson: string;
|
||||
rateLimitJson: string;
|
||||
metadataJson: string;
|
||||
};
|
||||
|
||||
const statuses = ['active', 'deprecated', 'hidden'];
|
||||
const fallbackTypes = [
|
||||
'text_generate',
|
||||
'image_generate',
|
||||
'image_edit',
|
||||
'image_analysis',
|
||||
'video_generate',
|
||||
'image_to_video',
|
||||
'omni_video',
|
||||
'text_embedding',
|
||||
'text_to_speech',
|
||||
'audio_generate',
|
||||
'digital_human_generate',
|
||||
'text_to_model',
|
||||
'image_to_model',
|
||||
'multiview_to_model',
|
||||
'mesh_edit',
|
||||
];
|
||||
const fallbackTypes = platformModelTypeDefinitions.map((item) => item.key);
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
providerKey: '',
|
||||
canonicalModelKey: '',
|
||||
providerModelName: '',
|
||||
modelType: 'text_generate',
|
||||
displayName: '',
|
||||
status: 'active',
|
||||
pricingVersion: '1',
|
||||
capabilitiesJson: '{}',
|
||||
billingJson: '{}',
|
||||
rateLimitJson: '{}',
|
||||
metadataJson: '{}',
|
||||
};
|
||||
function createEmptyForm(modelType = 'text_generate'): ModelForm {
|
||||
return {
|
||||
providerKey: '',
|
||||
canonicalModelKey: '',
|
||||
providerModelName: '',
|
||||
modelAlias: '',
|
||||
status: 'active',
|
||||
pricingRuleSetId: '',
|
||||
runtimePolicySetId: '',
|
||||
runtimePolicyOverrideJson: '{}',
|
||||
pricingVersion: '1',
|
||||
capabilities: createCapabilityEditorState(modelType),
|
||||
billingJson: '{}',
|
||||
rateLimitJson: '{}',
|
||||
metadataJson: '{}',
|
||||
};
|
||||
}
|
||||
|
||||
export function BaseModelCatalogPanel(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
message: string;
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
providers: CatalogProvider[];
|
||||
runtimePolicySets: RuntimePolicySet[];
|
||||
state: LoadState;
|
||||
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
|
||||
onResetAllBaseModels: () => Promise<void>;
|
||||
onResetBaseModel: (baseModelId: string) => Promise<void>;
|
||||
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<ModelForm>(emptyForm);
|
||||
const [form, setForm] = useState<ModelForm>(() => createEmptyForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [pendingDeleteModel, setPendingDeleteModel] = useState<BaseModelCatalogItem | null>(null);
|
||||
const [pendingResetModel, setPendingResetModel] = useState<BaseModelCatalogItem | null>(null);
|
||||
const [pendingResetAll, setPendingResetAll] = useState(false);
|
||||
|
||||
const providerNames = useMemo(
|
||||
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
|
||||
[props.providers],
|
||||
);
|
||||
const typeOptions = useMemo(
|
||||
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.map((item) => item.modelType).filter(Boolean)])),
|
||||
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.flatMap(readModelTypes)])),
|
||||
[props.baseModels],
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
|
||||
[props.baseModels, props.providers],
|
||||
);
|
||||
const defaultPricingRuleSetId = useMemo(() => defaultRuleSetId(props.pricingRuleSets), [props.pricingRuleSets]);
|
||||
const defaultRuntimePolicySetId = useMemo(() => defaultRuntimePolicyId(props.runtimePolicySets), [props.runtimePolicySets]);
|
||||
const systemModelCount = useMemo(() => props.baseModels.filter(isSystemBaseModel).length, [props.baseModels]);
|
||||
const customizedSystemModelCount = useMemo(
|
||||
() => props.baseModels.filter((item) => isSystemBaseModel(item) && item.customizedAt).length,
|
||||
[props.baseModels],
|
||||
);
|
||||
const filteredModels = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return props.baseModels.filter((item) => {
|
||||
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
|
||||
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
|
||||
const text = `${item.displayName} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
|
||||
const text = `${baseModelAlias(item)} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
|
||||
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
|
||||
});
|
||||
}, [props.baseModels, providerFilter, query, typeFilter]);
|
||||
@@ -107,34 +125,63 @@ export function BaseModelCatalogPanel(props: {
|
||||
function openCreateDialog() {
|
||||
const providerKey = providerOptions[0] ?? '';
|
||||
setEditingId('');
|
||||
setForm({ ...emptyForm, providerKey, canonicalModelKey: providerKey ? `${providerKey}:` : '' });
|
||||
setForm({
|
||||
...createEmptyForm(),
|
||||
providerKey,
|
||||
canonicalModelKey: providerKey ? `${providerKey}:` : '',
|
||||
pricingRuleSetId: defaultPricingRuleSetId,
|
||||
runtimePolicySetId: defaultRuntimePolicySetId,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editModel(model: BaseModelCatalogItem) {
|
||||
setEditingId(model.id);
|
||||
setForm(modelToForm(model));
|
||||
setForm({
|
||||
...modelToForm(model),
|
||||
pricingRuleSetId: model.pricingRuleSetId || defaultPricingRuleSetId,
|
||||
runtimePolicySetId: model.runtimePolicySetId || defaultRuntimePolicySetId,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setForm(createEmptyForm());
|
||||
setLocalError('');
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
async function deleteModel(model: BaseModelCatalogItem) {
|
||||
const confirmed = window.confirm(`确认删除基准模型 ${model.displayName}?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await props.onDeleteBaseModel(model.id);
|
||||
setPendingDeleteModel(null);
|
||||
if (editingId === model.id) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模型删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModel(model: BaseModelCatalogItem) {
|
||||
try {
|
||||
await props.onResetBaseModel(model.id);
|
||||
setPendingResetModel(null);
|
||||
if (editingId === model.id) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模型重置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAllModels() {
|
||||
try {
|
||||
await props.onResetAllBaseModels();
|
||||
setPendingResetAll(false);
|
||||
if (editingId) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模型批量重置失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
@@ -145,10 +192,16 @@ export function BaseModelCatalogPanel(props: {
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>默认模型来自原 server-main integration-platform,保留模型类型、能力、图标、计费和限流元数据。</p>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增模型
|
||||
</Button>
|
||||
<div className="inlineActions">
|
||||
<Button type="button" variant="outline" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
|
||||
<RotateCcw size={15} />
|
||||
重置所有
|
||||
</Button>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modelCatalogFilters">
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
|
||||
@@ -170,9 +223,12 @@ export function BaseModelCatalogPanel(props: {
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
|
||||
providerName={providerNames.get(model.providerKey)}
|
||||
onDelete={() => void deleteModel(model)}
|
||||
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
|
||||
onDelete={() => setPendingDeleteModel(model)}
|
||||
onEdit={() => editModel(model)}
|
||||
onReset={() => setPendingResetModel(model)}
|
||||
/>
|
||||
))}
|
||||
{!filteredModels.length && (
|
||||
@@ -206,86 +262,130 @@ export function BaseModelCatalogPanel(props: {
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>
|
||||
Provider
|
||||
<FormItem label="Provider">
|
||||
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
|
||||
<option value="">选择厂商</option>
|
||||
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
模型类型
|
||||
<Select value={form.modelType} onChange={(event) => setForm({ ...form, modelType: event.target.value })}>
|
||||
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
模型名
|
||||
</FormItem>
|
||||
<FormItem label="模型名">
|
||||
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
展示名称
|
||||
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
|
||||
</Label>
|
||||
<Label className="spanTwo">
|
||||
Canonical Key
|
||||
</FormItem>
|
||||
<FormItem
|
||||
description="作为调用和展示的模型名;多个不同平台模型设置同一别名后,可按该别名自动负载。"
|
||||
label="模型别名"
|
||||
>
|
||||
<Input value={form.modelAlias} onChange={(event) => setForm({ ...form, modelAlias: event.target.value })} />
|
||||
</FormItem>
|
||||
<FormItem className="spanTwo" label="Canonical Key">
|
||||
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
状态
|
||||
</FormItem>
|
||||
<FormItem label="状态">
|
||||
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
|
||||
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
定价版本
|
||||
</FormItem>
|
||||
<FormItem label="计价规则">
|
||||
<Select value={form.pricingRuleSetId || defaultPricingRuleSetId} onChange={(event) => setForm({ ...form, pricingRuleSetId: event.target.value })}>
|
||||
{!props.pricingRuleSets.length && <option value="">暂无可用计价规则</option>}
|
||||
{props.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="运行策略">
|
||||
<Select value={form.runtimePolicySetId || defaultRuntimePolicySetId} onChange={(event) => setForm({ ...form, runtimePolicySetId: event.target.value })}>
|
||||
{!props.runtimePolicySets.length && <option value="">暂无可用运行策略</option>}
|
||||
{props.runtimePolicySets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="定价版本">
|
||||
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
|
||||
</Label>
|
||||
<JsonField label="能力 JSON" value={form.capabilitiesJson} onChange={(value) => setForm({ ...form, capabilitiesJson: value })} />
|
||||
</FormItem>
|
||||
<BaseModelCapabilityEditor
|
||||
modelType={primaryCapabilityType(form.capabilities)}
|
||||
typeOptions={typeOptions}
|
||||
value={form.capabilities}
|
||||
onChange={(capabilities) => setForm({ ...form, capabilities })}
|
||||
/>
|
||||
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
|
||||
<JsonField label="限流 JSON" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
|
||||
<JsonField label="旧版限流 JSON(兼容字段)" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
|
||||
<JsonField label="运行策略覆盖 JSON" value={form.runtimePolicyOverrideJson} onChange={(value) => setForm({ ...form, runtimePolicyOverrideJson: value })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除模型"
|
||||
description="删除后该基准模型不可恢复,已关联的平台模型可能无法继续跟随基准配置。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteModel)}
|
||||
title={`确认删除基准模型 ${baseModelAlias(pendingDeleteModel)}?`}
|
||||
onCancel={() => setPendingDeleteModel(null)}
|
||||
onConfirm={() => pendingDeleteModel ? deleteModel(pendingDeleteModel) : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
confirmLabel="重置为默认"
|
||||
description="系统内置模型会恢复到默认模型库快照,当前手动修改的能力、计价、运行策略和元数据会被默认值覆盖。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingResetModel)}
|
||||
title={`确认重置 ${baseModelAlias(pendingResetModel)}?`}
|
||||
onCancel={() => setPendingResetModel(null)}
|
||||
onConfirm={() => pendingResetModel ? resetModel(pendingResetModel) : undefined}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
confirmLabel="重置所有"
|
||||
description={`将恢复 ${systemModelCount} 个系统内置基准模型的默认快照,其中 ${customizedSystemModelCount} 个显示为已修改。用户添加的模型不会被影响。`}
|
||||
loading={props.state === 'loading'}
|
||||
open={pendingResetAll}
|
||||
title="确认重置所有系统内置模型?"
|
||||
onCancel={() => setPendingResetAll(false)}
|
||||
onConfirm={resetAllModels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard(props: {
|
||||
model: BaseModelCatalogItem;
|
||||
pricingRuleSetName: string;
|
||||
providerName?: string;
|
||||
runtimePolicySetName: string;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const types = readModelTypes(props.model);
|
||||
const chips = [...types.slice(0, 5), ...(types.length > 5 ? [`+${types.length - 5}`] : [])];
|
||||
const isSystem = isSystemBaseModel(props.model);
|
||||
return (
|
||||
<article className="baseModelCard">
|
||||
<ModelIcon model={props.model} />
|
||||
<div className="baseModelCardBody">
|
||||
<div>
|
||||
<strong>{props.model.displayName}</strong>
|
||||
<span>{props.model.providerModelName}</span>
|
||||
</div>
|
||||
<div className="providerCatalogMeta">
|
||||
<Badge variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>
|
||||
<span>{props.providerName ?? props.model.providerKey}</span>
|
||||
<span>{props.model.canonicalModelKey}</span>
|
||||
</div>
|
||||
<div className="modelAbilityChips">
|
||||
{types.slice(0, 5).map((item) => <span key={item}>{item}</span>)}
|
||||
{types.length > 5 && <span>+{types.length - 5}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
<ModelCatalogCard
|
||||
actions={(
|
||||
<>
|
||||
{isSystem && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onReset}>
|
||||
<RotateCcw size={14} />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
badges={[
|
||||
<Badge key="status" variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>,
|
||||
<Badge key="catalog-type" variant={isSystem ? 'secondary' : 'outline'}>{baseModelCatalogTypeLabel(props.model)}</Badge>,
|
||||
...(isSystem && props.model.customizedAt ? [<Badge key="customized" variant="warning">已修改</Badge>] : []),
|
||||
]}
|
||||
chips={chips}
|
||||
iconPath={readModelIconPath(props.model)}
|
||||
iconText={props.model.providerKey}
|
||||
meta={[props.providerName ?? props.model.providerKey, props.pricingRuleSetName, props.runtimePolicySetName, props.model.canonicalModelKey]}
|
||||
subtitle={props.model.providerModelName}
|
||||
title={baseModelAlias(props.model)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,54 +398,115 @@ function JsonField(props: { label: string; value: string; onChange: (value: stri
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon(props: { model: BaseModelCatalogItem }) {
|
||||
const iconPath = readString(props.model.metadata?.iconPath) || readString(readRecord(props.model.metadata?.rawModel)?.icon_path);
|
||||
if (iconPath) {
|
||||
return (
|
||||
<div className="providerCatalogLogo">
|
||||
<img src={iconPath} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="providerCatalogLogo">{props.model.providerKey.slice(0, 2).toUpperCase()}</div>;
|
||||
}
|
||||
|
||||
function modelToForm(model: BaseModelCatalogItem): ModelForm {
|
||||
const capabilities = readObject(model.capabilities);
|
||||
const metadataTypes = Array.isArray(model.metadata?.originalTypes) ? { originalTypes: model.metadata.originalTypes } : {};
|
||||
const modelTypes = readModelTypes(model);
|
||||
const capabilityState = capabilitiesToForm({ ...capabilities, ...metadataTypes }, modelTypes[0] ?? 'text_generate');
|
||||
return {
|
||||
providerKey: model.providerKey,
|
||||
canonicalModelKey: model.canonicalModelKey,
|
||||
providerModelName: model.providerModelName,
|
||||
modelType: model.modelType,
|
||||
displayName: model.displayName,
|
||||
modelAlias: baseModelAlias(model),
|
||||
status: model.status,
|
||||
pricingRuleSetId: model.pricingRuleSetId ?? '',
|
||||
runtimePolicySetId: model.runtimePolicySetId ?? '',
|
||||
pricingVersion: String(model.pricingVersion || 1),
|
||||
capabilitiesJson: stringifyJson(model.capabilities),
|
||||
capabilities: capabilityState,
|
||||
billingJson: stringifyJson(model.baseBillingConfig),
|
||||
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
|
||||
runtimePolicyOverrideJson: stringifyJson(model.runtimePolicyOverride),
|
||||
metadataJson: stringifyJson(model.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function readModelIconPath(model: BaseModelCatalogItem) {
|
||||
return readString(model.metadata?.iconPath) || readString(readRecord(model.metadata?.rawModel)?.icon_path);
|
||||
}
|
||||
|
||||
function baseModelAlias(model?: BaseModelCatalogItem | null) {
|
||||
return model?.modelAlias || model?.displayName || model?.providerModelName || '';
|
||||
}
|
||||
|
||||
function primaryCapabilityType(capabilities: CapabilityEditorState) {
|
||||
return capabilities.types[0] ?? 'text_generate';
|
||||
}
|
||||
|
||||
function isSystemBaseModel(model: BaseModelCatalogItem) {
|
||||
if (model.catalogType) return model.catalogType === 'system';
|
||||
return model.metadata?.source === 'server-main.integration-platform' || Boolean(model.metadata?.rawModel || model.metadata?.sourceProviderCode);
|
||||
}
|
||||
|
||||
function baseModelCatalogTypeLabel(model: BaseModelCatalogItem) {
|
||||
return isSystemBaseModel(model) ? '系统内置' : '用户添加';
|
||||
}
|
||||
|
||||
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
|
||||
const modelTypes = uniqueStrings(form.capabilities.types).filter(Boolean);
|
||||
const primaryType = modelTypes[0] ?? 'text_generate';
|
||||
const capabilities = syncCapabilityTypes(form.capabilities, modelTypes);
|
||||
const modelAlias = form.modelAlias.trim() || form.providerModelName.trim();
|
||||
const metadata = parseJsonObject(form.metadataJson, '元数据 JSON');
|
||||
return {
|
||||
providerKey: form.providerKey.trim(),
|
||||
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
|
||||
providerModelName: form.providerModelName.trim(),
|
||||
modelType: form.modelType.trim(),
|
||||
displayName: form.displayName.trim() || form.providerModelName.trim(),
|
||||
capabilities: parseJsonObject(form.capabilitiesJson, '能力 JSON'),
|
||||
modelType: modelTypes.length ? modelTypes : [primaryType],
|
||||
modelAlias,
|
||||
capabilities: capabilitiesFromForm(syncPrimaryCapability(capabilities, primaryType)),
|
||||
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
|
||||
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
|
||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||
pricingRuleSetId: form.pricingRuleSetId.trim() || undefined,
|
||||
runtimePolicySetId: form.runtimePolicySetId.trim() || undefined,
|
||||
runtimePolicyOverride: parseJsonObject(form.runtimePolicyOverrideJson, '运行策略覆盖 JSON'),
|
||||
metadata: { ...metadata, alias: modelAlias, originalTypes: modelTypes },
|
||||
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]) {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function syncCapabilityTypes(state: CapabilityEditorState, modelTypes: string[]): CapabilityEditorState {
|
||||
const nextTypes = uniqueStrings(modelTypes).filter(Boolean);
|
||||
if (!nextTypes.length) return state;
|
||||
let next = state;
|
||||
nextTypes.forEach((type) => {
|
||||
next = addCapabilityType(next, type);
|
||||
});
|
||||
next.types
|
||||
.filter((type) => !nextTypes.includes(type))
|
||||
.forEach((type) => {
|
||||
next = removeCapabilityType(next, type);
|
||||
});
|
||||
return { ...next, types: nextTypes };
|
||||
}
|
||||
|
||||
function readModelTypes(model: BaseModelCatalogItem) {
|
||||
if (Array.isArray(model.modelType)) return model.modelType.map(String);
|
||||
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
|
||||
if (Array.isArray(values)) return values.map(String);
|
||||
return [model.modelType].filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultRuleSetId(ruleSets: PricingRuleSet[]) {
|
||||
return ruleSets.find((item) => item.ruleSetKey === 'default-multimodal-v1')?.id ?? ruleSets[0]?.id ?? '';
|
||||
}
|
||||
|
||||
function defaultRuntimePolicyId(policySets: RuntimePolicySet[]) {
|
||||
return policySets.find((item) => item.policyKey === 'default-runtime-v1')?.id ?? policySets[0]?.id ?? '';
|
||||
}
|
||||
|
||||
function pricingRuleSetName(ruleSetId: string | undefined, ruleSets: PricingRuleSet[]) {
|
||||
if (!ruleSetId) return defaultRuleSetId(ruleSets) ? '默认计价规则' : '未绑定计价规则';
|
||||
return ruleSets.find((item) => item.id === ruleSetId)?.name ?? '未知计价规则';
|
||||
}
|
||||
|
||||
function runtimePolicySetName(policySetId: string | undefined, policySets: RuntimePolicySet[]) {
|
||||
if (!policySetId) return defaultRuntimePolicyId(policySets) ? '默认运行策略' : '未绑定运行策略';
|
||||
return policySets.find((item) => item.id === policySetId)?.name ?? '未知运行策略';
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string) {
|
||||
@@ -361,10 +522,14 @@ function parseJsonObject(value: string, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJson(value?: Record<string, unknown>) {
|
||||
function stringifyJson(value?: object) {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function readObject(value: unknown) {
|
||||
return value && !Array.isArray(value) && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Building2, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
|
||||
import type {
|
||||
GatewayTenant,
|
||||
GatewayTenantUpsertRequest,
|
||||
GatewayUser,
|
||||
GatewayUserUpsertRequest,
|
||||
UserGroup,
|
||||
UserGroupUpsertRequest,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
FormDialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from '../../components/ui';
|
||||
import type { ConsoleData } from '../../app-state';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type TenantForm = {
|
||||
tenantKey: string;
|
||||
source: string;
|
||||
externalTenantId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultUserGroupId: string;
|
||||
planKey: string;
|
||||
billingProfileJson: string;
|
||||
rateLimitPolicyJson: string;
|
||||
authPolicyJson: string;
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type UserForm = {
|
||||
userKey: string;
|
||||
source: string;
|
||||
externalUserId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatarUrl: string;
|
||||
password: string;
|
||||
gatewayTenantId: string;
|
||||
tenantId: string;
|
||||
tenantKey: string;
|
||||
defaultUserGroupId: string;
|
||||
roles: string;
|
||||
authProfileJson: string;
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type UserGroupForm = {
|
||||
groupKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
priority: string;
|
||||
rechargeDiscountPolicyJson: string;
|
||||
billingDiscountPolicyJson: string;
|
||||
rateLimitPolicyJson: string;
|
||||
quotaPolicyJson: string;
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const tenantStatuses = ['active', 'disabled', 'locked'];
|
||||
const userStatuses = ['active', 'disabled', 'locked'];
|
||||
const userGroupStatuses = ['active', 'disabled'];
|
||||
const sourceOptions = ['gateway', 'server-main', 'sync'];
|
||||
const roleOptions = [
|
||||
{ label: '普通用户', value: 'user' },
|
||||
{ label: '创作者', value: 'creator' },
|
||||
{ label: '管理', value: 'operator' },
|
||||
{ label: '超级管理员', value: 'manager' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
];
|
||||
|
||||
export function TenantsPanel(props: IdentityPanelProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<TenantForm>(() => defaultTenantForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<GatewayTenant | null>(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(defaultTenantForm());
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editTenant(tenant: GatewayTenant) {
|
||||
setEditingId(tenant.id);
|
||||
setLocalError('');
|
||||
setForm(tenantToForm(tenant));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveTenant(formToTenantPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '保存租户失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTenant(tenant: GatewayTenant) {
|
||||
try {
|
||||
await props.onDeleteTenant(tenant.id);
|
||||
setPendingDeleteTenant(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '删除租户失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<IdentityHeader
|
||||
count={props.data.tenants.length}
|
||||
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
|
||||
icon={<Building2 size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
title="租户管理"
|
||||
actionLabel="新增租户"
|
||||
onCreate={openCreateDialog}
|
||||
/>
|
||||
{props.data.tenants.length ? (
|
||||
<Table className="identityDataTable tenantTable">
|
||||
<TableRow>
|
||||
<TableHead>租户</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>默认用户组</TableHead>
|
||||
<TableHead>套餐</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{props.data.tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell><IdentityName title={tenant.name} subtitle={tenant.tenantKey} /></TableCell>
|
||||
<TableCell>{tenant.source}</TableCell>
|
||||
<TableCell>{groupName(props.data.userGroups, tenant.defaultUserGroupId)}</TableCell>
|
||||
<TableCell>{tenant.planKey || '未设置'}</TableCell>
|
||||
<TableCell><Badge variant={tenant.status === 'active' ? 'success' : 'secondary'}>{tenant.status}</Badge></TableCell>
|
||||
<TableCell><TableActions onEdit={() => editTenant(tenant)} onDelete={() => setPendingDeleteTenant(tenant)} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyIdentity title="暂无租户" description="点击新增租户建立本地租户闭环。" />
|
||||
)}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑租户' : '新增租户'}
|
||||
className="identityDialog"
|
||||
eyebrow={editingId ? 'Edit Tenant' : 'New Tenant'}
|
||||
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑租户' : '新增租户'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>租户 Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} placeholder="default" /></Label>
|
||||
<Label>租户名称<Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认租户" /></Label>
|
||||
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
|
||||
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{tenantStatuses.map(option)}</Select></Label>
|
||||
<Label>外部租户 ID<Input size="sm" value={form.externalTenantId} onChange={(event) => setForm({ ...form, externalTenantId: event.target.value })} placeholder="server-main tenant id" /></Label>
|
||||
<Label>套餐 Key<Input size="sm" value={form.planKey} onChange={(event) => setForm({ ...form, planKey: event.target.value })} placeholder="free / pro" /></Label>
|
||||
<Label className="spanTwo">默认用户组<Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value="">未设置</option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
|
||||
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="内部备注" /></Label>
|
||||
<JsonField label="计费资料 JSON" value={form.billingProfileJson} onChange={(value) => setForm({ ...form, billingProfileJson: value })} />
|
||||
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
|
||||
<JsonField label="授权策略 JSON" value={form.authPolicyJson} onChange={(value) => setForm({ ...form, authPolicyJson: value })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除租户"
|
||||
description="租户会被软删除,关联用户不会被自动删除。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteTenant)}
|
||||
title={`确认删除租户 ${pendingDeleteTenant?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDeleteTenant(null)}
|
||||
onConfirm={() => pendingDeleteTenant ? deleteTenant(pendingDeleteTenant) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersPanel(props: IdentityPanelProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<UserForm>(() => defaultUserForm(props.data.tenants[0]));
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
|
||||
|
||||
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(defaultUserForm(props.data.tenants[0]));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editUser(user: GatewayUser) {
|
||||
setEditingId(user.id);
|
||||
setLocalError('');
|
||||
setForm(userToForm(user));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveUser(formToUserPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '保存用户失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user: GatewayUser) {
|
||||
try {
|
||||
await props.onDeleteUser(user.id);
|
||||
setPendingDeleteUser(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '删除用户失败');
|
||||
}
|
||||
}
|
||||
|
||||
function selectTenant(gatewayTenantId: string) {
|
||||
const tenant = tenantById.get(gatewayTenantId);
|
||||
setForm({ ...form, gatewayTenantId, tenantKey: tenant?.tenantKey ?? form.tenantKey });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<IdentityHeader
|
||||
count={props.data.users.length}
|
||||
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
|
||||
icon={<UserRound size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
title="用户管理"
|
||||
actionLabel="新增用户"
|
||||
onCreate={openCreateDialog}
|
||||
/>
|
||||
{props.data.users.length ? (
|
||||
<Table className="identityDataTable userTable">
|
||||
<TableRow>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>租户</TableHead>
|
||||
<TableHead>用户组</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{props.data.users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
|
||||
<TableCell>{roleLabel(user.roles)}</TableCell>
|
||||
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
|
||||
<TableCell>{groupName(props.data.userGroups, user.defaultUserGroupId)}</TableCell>
|
||||
<TableCell>{user.source}</TableCell>
|
||||
<TableCell><Badge variant={user.status === 'active' ? 'success' : 'secondary'}>{user.status}</Badge></TableCell>
|
||||
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyIdentity title="暂无用户" description="可先添加本地用户,后续再与 server-main 同步关系对齐。" />
|
||||
)}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑用户' : '新增用户'}
|
||||
className="identityDialog"
|
||||
eyebrow={editingId ? 'Edit User' : 'New User'}
|
||||
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑用户' : '新增用户'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>用户名<Input size="sm" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} placeholder="user@example.com" /></Label>
|
||||
<Label>用户 Key<Input size="sm" value={form.userKey} onChange={(event) => setForm({ ...form, userKey: event.target.value })} placeholder="留空自动生成" /></Label>
|
||||
<Label>显示名称<Input size="sm" value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} placeholder="王小明" /></Label>
|
||||
<Label>邮箱<Input size="sm" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@example.com" /></Label>
|
||||
<Label>手机号<Input size="sm" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} /></Label>
|
||||
<Label>头像 URL<Input size="sm" value={form.avatarUrl} onChange={(event) => setForm({ ...form, avatarUrl: event.target.value })} /></Label>
|
||||
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
|
||||
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userStatuses.map(option)}</Select></Label>
|
||||
<Label>租户<Select size="sm" value={form.gatewayTenantId} onChange={(event) => selectTenant(event.target.value)}><option value="">未设置</option>{props.data.tenants.map((tenant) => <option value={tenant.id} key={tenant.id}>{tenant.name}</option>)}</Select></Label>
|
||||
<Label>租户 Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} /></Label>
|
||||
<Label>外部用户 ID<Input size="sm" value={form.externalUserId} onChange={(event) => setForm({ ...form, externalUserId: event.target.value })} /></Label>
|
||||
<Label>业务租户 ID<Input size="sm" value={form.tenantId} onChange={(event) => setForm({ ...form, tenantId: event.target.value })} /></Label>
|
||||
<Label>默认用户组<Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value="">未设置</option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
|
||||
<Label>登录密码<Input size="sm" value={form.password} type="password" onChange={(event) => setForm({ ...form, password: event.target.value })} placeholder={editingId ? '留空不修改' : '可为空'} /></Label>
|
||||
<Label>
|
||||
角色
|
||||
<Select size="sm" value={form.roles} onChange={(event) => setForm({ ...form, roles: event.target.value })}>
|
||||
{roleOptions.map((role) => <option value={role.value} key={role.value}>{role.label}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<JsonField label="授权资料 JSON" value={form.authProfileJson} onChange={(value) => setForm({ ...form, authProfileJson: value })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除用户"
|
||||
description="用户会被软删除,已创建的任务记录会保留。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteUser)}
|
||||
title={`确认删除用户 ${pendingDeleteUser?.username ?? ''}?`}
|
||||
onCancel={() => setPendingDeleteUser(null)}
|
||||
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserGroupsPanel(props: IdentityPanelProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<UserGroupForm>(() => defaultUserGroupForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(defaultUserGroupForm());
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editGroup(group: UserGroup) {
|
||||
setEditingId(group.id);
|
||||
setLocalError('');
|
||||
setForm(userGroupToForm(group));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveUserGroup(formToUserGroupPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '保存用户组失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(group: UserGroup) {
|
||||
try {
|
||||
await props.onDeleteUserGroup(group.id);
|
||||
setPendingDeleteGroup(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '删除用户组失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<IdentityHeader
|
||||
count={props.data.userGroups.length}
|
||||
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
|
||||
icon={<UsersRound size={17} />}
|
||||
message={localError || props.operationMessage}
|
||||
title="用户组管理"
|
||||
actionLabel="新增用户组"
|
||||
onCreate={openCreateDialog}
|
||||
/>
|
||||
{props.data.userGroups.length ? (
|
||||
<Table className="identityDataTable groupTable">
|
||||
<TableRow>
|
||||
<TableHead>用户组</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>折扣</TableHead>
|
||||
<TableHead>限流</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
{props.data.userGroups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
|
||||
<TableCell>{group.source}</TableCell>
|
||||
<TableCell>{group.priority}</TableCell>
|
||||
<TableCell>{discountSummary(group)}</TableCell>
|
||||
<TableCell>{policyKeys(group.rateLimitPolicy).join(', ') || '未设置'}</TableCell>
|
||||
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
|
||||
<TableCell><TableActions onEdit={() => editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyIdentity title="暂无用户组" description="可先新增默认用户组,用来配置折扣和并发控制。" />
|
||||
)}
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑用户组' : '新增用户组'}
|
||||
className="identityDialog"
|
||||
eyebrow={editingId ? 'Edit User Group' : 'New User Group'}
|
||||
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑用户组' : '新增用户组'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>用户组 Key<Input size="sm" value={form.groupKey} onChange={(event) => setForm({ ...form, groupKey: event.target.value })} placeholder="default" /></Label>
|
||||
<Label>名称<Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认用户组" /></Label>
|
||||
<Label>来源<Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
|
||||
<Label>状态<Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
|
||||
<Label>优先级<Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">描述<Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||
<JsonField label="充值折扣策略 JSON" value={form.rechargeDiscountPolicyJson} onChange={(value) => setForm({ ...form, rechargeDiscountPolicyJson: value })} />
|
||||
<JsonField label="计费折扣策略 JSON" value={form.billingDiscountPolicyJson} onChange={(value) => setForm({ ...form, billingDiscountPolicyJson: value })} />
|
||||
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
|
||||
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除用户组"
|
||||
description="删除后租户和用户上引用该用户组的默认策略会失效。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteGroup)}
|
||||
title={`确认删除用户组 ${pendingDeleteGroup?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDeleteGroup(null)}
|
||||
onConfirm={() => pendingDeleteGroup ? deleteGroup(pendingDeleteGroup) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type IdentityPanelProps = {
|
||||
data: ConsoleData;
|
||||
operationMessage: string;
|
||||
state: LoadState;
|
||||
onDeleteTenant: (tenantId: string) => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onDeleteUserGroup: (groupId: string) => Promise<void>;
|
||||
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
|
||||
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
|
||||
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function IdentityHeader(props: {
|
||||
actionLabel: string;
|
||||
count: number;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
message?: string;
|
||||
title: string;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="identityHeaderTitle">
|
||||
<div className="iconBox">{props.icon}</div>
|
||||
<div>
|
||||
<CardTitle>{props.title}</CardTitle>
|
||||
<p className="mutedText">{props.description}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{props.count}</Badge>
|
||||
</div>
|
||||
<Button type="button" onClick={props.onCreate}><Plus size={15} />{props.actionLabel}</Button>
|
||||
</CardHeader>
|
||||
{props.message && <CardContent><p className="formMessage">{props.message}</p></CardContent>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityName(props: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<span className="identityTableName">
|
||||
<strong>{props.title}</strong>
|
||||
<small>{props.subtitle}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TableActions(props: { onDelete: () => void; onEdit: () => void }) {
|
||||
return (
|
||||
<span className="tableActions">
|
||||
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
|
||||
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyIdentity(props: { title: string; description: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="emptyState">
|
||||
<KeyRound size={18} />
|
||||
<strong>{props.title}</strong>
|
||||
<span>{props.description}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter(props: { editing: boolean; loading: boolean; onCancel: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} />取消</Button>
|
||||
<Button type="submit" disabled={props.loading}>{props.editing ? <Pencil size={15} /> : <Plus size={15} />}{props.editing ? '保存修改' : '新增'}</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Label className="spanTwo">
|
||||
{props.label}
|
||||
<Textarea size="sm" rows={3} value={props.value} placeholder='{"rules":[]}' onChange={(event) => props.onChange(event.target.value)} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function option(value: string) {
|
||||
return <option value={value} key={value}>{value}</option>;
|
||||
}
|
||||
|
||||
function defaultTenantForm(): TenantForm {
|
||||
return {
|
||||
tenantKey: `tenant-${Date.now().toString(36)}`,
|
||||
source: 'gateway',
|
||||
externalTenantId: '',
|
||||
name: '默认租户',
|
||||
description: '',
|
||||
defaultUserGroupId: '',
|
||||
planKey: '',
|
||||
billingProfileJson: '{}',
|
||||
rateLimitPolicyJson: '{}',
|
||||
authPolicyJson: '{}',
|
||||
metadataJson: '{}',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function tenantToForm(tenant: GatewayTenant): TenantForm {
|
||||
return {
|
||||
tenantKey: tenant.tenantKey,
|
||||
source: tenant.source,
|
||||
externalTenantId: tenant.externalTenantId ?? '',
|
||||
name: tenant.name,
|
||||
description: tenant.description ?? '',
|
||||
defaultUserGroupId: tenant.defaultUserGroupId ?? '',
|
||||
planKey: tenant.planKey ?? '',
|
||||
billingProfileJson: stringifyJson(tenant.billingProfile),
|
||||
rateLimitPolicyJson: stringifyJson(tenant.rateLimitPolicy),
|
||||
authPolicyJson: stringifyJson(tenant.authPolicy),
|
||||
metadataJson: stringifyJson(tenant.metadata),
|
||||
status: tenant.status,
|
||||
};
|
||||
}
|
||||
|
||||
function formToTenantPayload(form: TenantForm): GatewayTenantUpsertRequest {
|
||||
return {
|
||||
tenantKey: form.tenantKey.trim(),
|
||||
source: form.source,
|
||||
externalTenantId: form.externalTenantId.trim() || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
defaultUserGroupId: form.defaultUserGroupId || undefined,
|
||||
planKey: form.planKey.trim() || undefined,
|
||||
billingProfile: parseJsonObject(form.billingProfileJson, '计费资料 JSON'),
|
||||
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
|
||||
authPolicy: parseJsonObject(form.authPolicyJson, '授权策略 JSON'),
|
||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultUserForm(tenant?: GatewayTenant): UserForm {
|
||||
return {
|
||||
userKey: '',
|
||||
source: 'gateway',
|
||||
externalUserId: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
avatarUrl: '',
|
||||
password: '',
|
||||
gatewayTenantId: tenant?.id ?? '',
|
||||
tenantId: '',
|
||||
tenantKey: tenant?.tenantKey ?? '',
|
||||
defaultUserGroupId: '',
|
||||
roles: 'user',
|
||||
authProfileJson: '{}',
|
||||
metadataJson: '{}',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function userToForm(user: GatewayUser): UserForm {
|
||||
return {
|
||||
userKey: user.userKey,
|
||||
source: user.source,
|
||||
externalUserId: user.externalUserId ?? '',
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
avatarUrl: user.avatarUrl ?? '',
|
||||
password: '',
|
||||
gatewayTenantId: user.gatewayTenantId ?? '',
|
||||
tenantId: user.tenantId ?? '',
|
||||
tenantKey: user.tenantKey ?? '',
|
||||
defaultUserGroupId: user.defaultUserGroupId ?? '',
|
||||
roles: roleValue(user.roles),
|
||||
authProfileJson: stringifyJson(user.authProfile),
|
||||
metadataJson: stringifyJson(user.metadata),
|
||||
status: user.status,
|
||||
};
|
||||
}
|
||||
|
||||
function formToUserPayload(form: UserForm): GatewayUserUpsertRequest {
|
||||
return {
|
||||
userKey: form.userKey.trim() || undefined,
|
||||
source: form.source,
|
||||
externalUserId: form.externalUserId.trim() || undefined,
|
||||
username: form.username.trim(),
|
||||
displayName: form.displayName.trim() || undefined,
|
||||
email: form.email.trim() || undefined,
|
||||
phone: form.phone.trim() || undefined,
|
||||
avatarUrl: form.avatarUrl.trim() || undefined,
|
||||
password: form.password || undefined,
|
||||
gatewayTenantId: form.gatewayTenantId || undefined,
|
||||
tenantId: form.tenantId.trim() || undefined,
|
||||
tenantKey: form.tenantKey.trim() || undefined,
|
||||
defaultUserGroupId: form.defaultUserGroupId || undefined,
|
||||
roles: [roleValue([form.roles])],
|
||||
authProfile: parseJsonObject(form.authProfileJson, '授权资料 JSON'),
|
||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultUserGroupForm(): UserGroupForm {
|
||||
return {
|
||||
groupKey: `group-${Date.now().toString(36)}`,
|
||||
name: '默认用户组',
|
||||
description: '',
|
||||
source: 'gateway',
|
||||
priority: '100',
|
||||
rechargeDiscountPolicyJson: '{}',
|
||||
billingDiscountPolicyJson: '{}',
|
||||
rateLimitPolicyJson: '{"rules":[]}',
|
||||
quotaPolicyJson: '{}',
|
||||
metadataJson: '{}',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function userGroupToForm(group: UserGroup): UserGroupForm {
|
||||
return {
|
||||
groupKey: group.groupKey,
|
||||
name: group.name,
|
||||
description: group.description ?? '',
|
||||
source: group.source,
|
||||
priority: String(group.priority),
|
||||
rechargeDiscountPolicyJson: stringifyJson(group.rechargeDiscountPolicy),
|
||||
billingDiscountPolicyJson: stringifyJson(group.billingDiscountPolicy),
|
||||
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
|
||||
quotaPolicyJson: stringifyJson(group.quotaPolicy),
|
||||
metadataJson: stringifyJson(group.metadata),
|
||||
status: group.status,
|
||||
};
|
||||
}
|
||||
|
||||
function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
|
||||
return {
|
||||
groupKey: form.groupKey.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
source: form.source,
|
||||
priority: Number(form.priority) || 100,
|
||||
rechargeDiscountPolicy: parseJsonObject(form.rechargeDiscountPolicyJson, '充值折扣策略 JSON'),
|
||||
billingDiscountPolicy: parseJsonObject(form.billingDiscountPolicyJson, '计费折扣策略 JSON'),
|
||||
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
|
||||
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
|
||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function tenantName(tenants: GatewayTenant[], tenantId?: string, fallback?: string) {
|
||||
if (!tenantId) return fallback || '未设置';
|
||||
return tenants.find((tenant) => tenant.id === tenantId)?.name ?? fallback ?? tenantId;
|
||||
}
|
||||
|
||||
function groupName(groups: UserGroup[], groupId?: string) {
|
||||
if (!groupId) return '未设置';
|
||||
return groups.find((group) => group.id === groupId)?.name ?? groupId;
|
||||
}
|
||||
|
||||
function roleValue(roles?: string[]) {
|
||||
if (roles?.includes('manager')) return 'manager';
|
||||
if (roles?.includes('admin')) return 'admin';
|
||||
if (roles?.includes('operator')) return 'operator';
|
||||
if (roles?.includes('creator')) return 'creator';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
function roleLabel(roles?: string[]) {
|
||||
return roleOptions.find((role) => role.value === roleValue(roles))?.label ?? '普通用户';
|
||||
}
|
||||
|
||||
function discountSummary(group: UserGroup) {
|
||||
const billing = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
|
||||
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
|
||||
const parts = [];
|
||||
if (billing) parts.push(`计费 ${billing}`);
|
||||
if (recharge) parts.push(`充值 ${recharge}`);
|
||||
return parts.join(' / ') || '未设置';
|
||||
}
|
||||
|
||||
function policyKeys(value?: Record<string, unknown>) {
|
||||
if (!value) return [];
|
||||
return Object.keys(value).slice(0, 3);
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown) {
|
||||
if (!value || (typeof value === 'object' && Object.keys(value as Record<string, unknown>).length === 0)) return '{}';
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string): Record<string, unknown> | undefined {
|
||||
const text = value.trim();
|
||||
if (!text || text === '{}') return undefined;
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 必须是 JSON 对象`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function ModelCatalogCard(props: {
|
||||
actions?: ReactNode;
|
||||
badges?: ReactNode[];
|
||||
chips?: string[];
|
||||
iconPath?: string;
|
||||
iconText: string;
|
||||
meta?: string[];
|
||||
subtitle: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="baseModelCard">
|
||||
<div className="providerCatalogLogo">
|
||||
{props.iconPath ? <img src={props.iconPath} alt="" /> : props.iconText.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="baseModelCardBody">
|
||||
<div>
|
||||
<strong>{props.title}</strong>
|
||||
<span>{props.subtitle}</span>
|
||||
</div>
|
||||
<div className="providerCatalogMeta">
|
||||
{props.badges?.map((badge, index) => <span key={`badge-${index}`}>{badge}</span>)}
|
||||
{props.meta?.map((item) => <span key={item}>{item}</span>)}
|
||||
</div>
|
||||
{!!props.chips?.length && (
|
||||
<div className="modelAbilityChips">
|
||||
{props.chips.map((item) => <span key={item}>{item}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.actions && <div className="providerCatalogActions">{props.actions}</div>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { ListChecks, Plus, Trash2 } from 'lucide-react';
|
||||
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Input, Label, Select } from '../../components/ui';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { calculatorLabel, PricingFormulaNote, PricingInlineField, PricingReadonlyField, unitLabel } from './PricingEditorControls';
|
||||
import { calculatorLabel, PricingFormulaNote, unitLabel } from './PricingEditorControls';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
type ModeKey = 'text' | 'image' | 'video';
|
||||
@@ -84,6 +84,8 @@ export function PricingRuleVisualEditor(props: {
|
||||
if (mode.key === 'image' && rawActiveRules.length) return [completeRule(mergeTemplateRules(mode.templates(props.currency), rawActiveRules)[0], mode)];
|
||||
return rawActiveRules.map((rule) => completeRule(rule, mode));
|
||||
}, [mode.key, props.currency, rawActiveRules]);
|
||||
const modeCounts = useMemo(() => modeRuleCounts(props.rules), [props.rules]);
|
||||
const modeSummary = useMemo(() => summarizePricingMode(mode, activeRules), [activeRules, mode]);
|
||||
|
||||
function replaceModeRules(nextRules: PricingRuleInput[]) {
|
||||
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
|
||||
@@ -97,28 +99,48 @@ export function PricingRuleVisualEditor(props: {
|
||||
<span>选择能力后维护基础价格、计费公式和计费参数。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pricingModeTabs">
|
||||
{modeDefinitions.map((item) => (
|
||||
<button className={item.key === activeMode ? 'active' : ''} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeRules.length ? (
|
||||
activeRules.map((rule, index) => (
|
||||
<ModeRuleEditor
|
||||
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
|
||||
mode={mode}
|
||||
rule={rule}
|
||||
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
|
||||
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="pricingModeEmpty">
|
||||
<strong>{mode.label} 还没有计价配置</strong>
|
||||
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} />添加默认配置</Button>
|
||||
<div className="pricingModeWorkbench">
|
||||
<aside className="pricingModeSidebar">
|
||||
<div className="pricingModeSidebarTitle">
|
||||
<ListChecks size={15} />
|
||||
<strong>计费模式</strong>
|
||||
</div>
|
||||
<div className="pricingModeList">
|
||||
{modeDefinitions.map((item) => (
|
||||
<button data-active={item.key === activeMode} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>
|
||||
<span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{modeCounts[item.key] ?? 0} 条计价规则</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pricingModeSummary">
|
||||
<strong>规则总结</strong>
|
||||
<p>{modeSummary}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="pricingModePanel">
|
||||
{activeRules.length ? (
|
||||
activeRules.map((rule, index) => (
|
||||
<ModeRuleEditor
|
||||
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
|
||||
mode={mode}
|
||||
rule={rule}
|
||||
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
|
||||
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="pricingModeEmpty">
|
||||
<strong>{mode.label} 还没有计价配置</strong>
|
||||
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} />添加默认配置</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +149,38 @@ export function createDefaultPricingRules(currency: string): PricingRuleInput[]
|
||||
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
|
||||
}
|
||||
|
||||
function modeRuleCounts(rules: PricingRuleInput[]): Record<ModeKey, number> {
|
||||
return Object.fromEntries(
|
||||
modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]),
|
||||
) as Record<ModeKey, number>;
|
||||
}
|
||||
|
||||
function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
|
||||
if (!rules.length) return `${mode.label}暂无计价配置,可一键添加默认规则。`;
|
||||
const rule = rules[0];
|
||||
if (mode.key === 'text') {
|
||||
const prices = textPrices(rule);
|
||||
return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}。`;
|
||||
}
|
||||
const groupSummaries = mode.parameterGroups
|
||||
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
|
||||
.filter(Boolean);
|
||||
const details = groupSummaries.length ? `\n${groupSummaries.join('\n')}` : '';
|
||||
return `${mode.label}按 ${unitLabel(rule.unit)} 计费,基础单价 ${rule.basePrice},计算方式为${calculatorLabel(rule.calculatorType)}${details}。`;
|
||||
}
|
||||
|
||||
function summarizeWeightGroup(group: ParameterGroup, value: RecordValue) {
|
||||
const items = Object.entries(value)
|
||||
.map(([key, weight]) => `${summaryWeightLabel(group, key)} ×${scalarToString(weight)}`)
|
||||
.join('、');
|
||||
return items ? `${group.title}:${items}` : '';
|
||||
}
|
||||
|
||||
function summaryWeightLabel(group: ParameterGroup, key: string) {
|
||||
const label = group.labels?.[key] ?? key;
|
||||
return label.replace(/\s*\([^)]*\)\s*$/, '');
|
||||
}
|
||||
|
||||
function ModeRuleEditor(props: {
|
||||
mode: ModeDefinition;
|
||||
rule: PricingRuleInput;
|
||||
@@ -158,18 +212,19 @@ function ModeRuleEditor(props: {
|
||||
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} />删除</Button>
|
||||
</header>
|
||||
|
||||
<div className="pricingModeBaseRows compact">
|
||||
<Label>展示名称<Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
|
||||
<Label>状态<Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
|
||||
</div>
|
||||
|
||||
<div className="pricingModeInlineRows">
|
||||
<PricingInlineField label={`基础单价/${unitLabel(rule.unit)}`}>
|
||||
<Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||||
</PricingInlineField>
|
||||
<PricingReadonlyField label="计价单位" value={unitLabel(rule.unit)} />
|
||||
<PricingReadonlyField label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
||||
</div>
|
||||
<PricingFormRows>
|
||||
<PricingFormRow label="展示名称">
|
||||
<Input size="sm" value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} />
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="状态">
|
||||
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
|
||||
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
|
||||
</PricingFormRow>
|
||||
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
|
||||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
|
||||
</PricingFormRows>
|
||||
|
||||
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
|
||||
|
||||
@@ -238,25 +293,50 @@ function TextRuleEditor(props: {
|
||||
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} />删除</Button>
|
||||
</header>
|
||||
|
||||
<div className="pricingModeBaseRows compact">
|
||||
<Label>展示名称<Input value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /></Label>
|
||||
<Label>状态<Select value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
|
||||
</div>
|
||||
|
||||
<div className="pricingTextPriceGrid">
|
||||
<PricingInlineField label="输入单价/1K tokens">
|
||||
<Input min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
|
||||
</PricingInlineField>
|
||||
<PricingInlineField label="输出单价/1K tokens">
|
||||
<Input min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
|
||||
</PricingInlineField>
|
||||
</div>
|
||||
<PricingFormRows>
|
||||
<PricingFormRow label="展示名称">
|
||||
<Input size="sm" value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} />
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="状态">
|
||||
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="输入单价/1K tokens">
|
||||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
|
||||
</PricingFormRow>
|
||||
<PricingFormRow label="输出单价/1K tokens">
|
||||
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
|
||||
</PricingFormRow>
|
||||
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
|
||||
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
|
||||
</PricingFormRows>
|
||||
|
||||
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingFormRows(props: { children: ReactNode }) {
|
||||
return <div className="pricingFormRows">{props.children}</div>;
|
||||
}
|
||||
|
||||
function PricingFormRow(props: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="pricingFormRow">
|
||||
<strong>{props.label}</strong>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingReadonlyRow(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="pricingFormRow">
|
||||
<strong>{props.label}</strong>
|
||||
<span className="pricingFormReadonly">{props.value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeightTable(props: {
|
||||
editableTitle?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
|
||||
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
import { unitLabel } from './PricingEditorControls';
|
||||
import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
|
||||
@@ -33,6 +33,7 @@ export function PricingRulesPanel(props: {
|
||||
const [form, setForm] = useState<PricingForm>(() => createEmptyForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [pendingDeleteRuleSet, setPendingDeleteRuleSet] = useState<PricingRuleSet | null>(null);
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return props.pricingRuleSets;
|
||||
@@ -76,10 +77,13 @@ export function PricingRulesPanel(props: {
|
||||
}
|
||||
|
||||
async function deleteRuleSet(ruleSet: PricingRuleSet) {
|
||||
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
|
||||
if (!confirmed) return;
|
||||
if (isDefaultRuleSet(ruleSet)) {
|
||||
setLocalError('默认计价规则不能删除。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onDeletePricingRuleSet(ruleSet.id);
|
||||
setPendingDeleteRuleSet(null);
|
||||
if (editingId === ruleSet.id) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '定价规则删除失败');
|
||||
@@ -131,7 +135,17 @@ export function PricingRulesPanel(props: {
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} />修改</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteRuleSet(ruleSet)}><Trash2 size={14} />删除</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultRuleSet(ruleSet)}
|
||||
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
|
||||
onClick={() => setPendingDeleteRuleSet(ruleSet)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -165,6 +179,15 @@ export function PricingRulesPanel(props: {
|
||||
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
|
||||
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除规则"
|
||||
description="已绑定的平台或模型会清空规则绑定,删除后不可恢复。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteRuleSet)}
|
||||
title={`确认删除定价规则 ${pendingDeleteRuleSet?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDeleteRuleSet(null)}
|
||||
onConfirm={() => pendingDeleteRuleSet ? deleteRuleSet(pendingDeleteRuleSet) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -273,6 +296,10 @@ function formatPrice(value: number) {
|
||||
return Number(value.toFixed(4)).toString();
|
||||
}
|
||||
|
||||
function isDefaultRuleSet(ruleSet: PricingRuleSet) {
|
||||
return ruleSet.ruleSetKey === 'default-multimodal-v1';
|
||||
}
|
||||
|
||||
function ensureRules(rules: PricingRuleInput[], currency: string): PricingRuleInput[] {
|
||||
const sourceRules = rules.length ? rules : createDefaultPricingRules(currency);
|
||||
return sourceRules.map((rule) => ({ ...rule, currency }));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import type { CatalogProvider, CatalogProviderUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type ProviderForm = {
|
||||
@@ -9,6 +9,8 @@ type ProviderForm = {
|
||||
displayName: string;
|
||||
providerType: string;
|
||||
iconPath: string;
|
||||
defaultBaseUrl: string;
|
||||
defaultAuthType: string;
|
||||
source: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -18,6 +20,8 @@ const emptyForm: ProviderForm = {
|
||||
displayName: '',
|
||||
providerType: 'openai',
|
||||
iconPath: '',
|
||||
defaultBaseUrl: '',
|
||||
defaultAuthType: 'APIKey',
|
||||
source: 'gateway',
|
||||
status: 'active',
|
||||
};
|
||||
@@ -51,6 +55,7 @@ const defaultSpecTypes = [
|
||||
'n8n',
|
||||
];
|
||||
const providerStatuses = ['active', 'deprecated', 'hidden'];
|
||||
const authTypeOptions = ['APIKey', 'Token', 'AccessKey-SecretKey', 'none'];
|
||||
|
||||
export function ProviderManagementPanel(props: {
|
||||
message: string;
|
||||
@@ -62,6 +67,7 @@ export function ProviderManagementPanel(props: {
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<ProviderForm>(emptyForm);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<CatalogProvider | null>(null);
|
||||
const editingProvider = useMemo(
|
||||
() => props.providers.find((item) => item.id === editingId),
|
||||
[editingId, props.providers],
|
||||
@@ -95,6 +101,8 @@ export function ProviderManagementPanel(props: {
|
||||
displayName: provider.displayName,
|
||||
providerType: provider.providerType,
|
||||
iconPath: provider.iconPath ?? '',
|
||||
defaultBaseUrl: provider.defaultBaseUrl ?? '',
|
||||
defaultAuthType: provider.defaultAuthType ?? 'APIKey',
|
||||
source: provider.source ?? 'gateway',
|
||||
status: provider.status,
|
||||
});
|
||||
@@ -108,10 +116,9 @@ export function ProviderManagementPanel(props: {
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: CatalogProvider) {
|
||||
const confirmed = window.confirm(`确认删除模型厂商 ${provider.displayName}?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await props.onDeleteProvider(provider.id);
|
||||
setPendingDeleteProvider(null);
|
||||
if (editingId === provider.id) {
|
||||
closeDialog();
|
||||
}
|
||||
@@ -129,7 +136,7 @@ export function ProviderManagementPanel(props: {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>默认数据来自原 server-main integration-platform,包含厂商名称、code 和图标。</p>
|
||||
<p>默认数据来自原 server-main integration-platform,包含厂商名称、code、图标、Base URL 和授权类型。</p>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增厂商
|
||||
@@ -151,6 +158,8 @@ export function ProviderManagementPanel(props: {
|
||||
<div className="providerCatalogMeta">
|
||||
<Badge variant={provider.status === 'active' ? 'success' : 'secondary'}>{provider.status}</Badge>
|
||||
<span>spec_type: {provider.providerType}</span>
|
||||
<span>{provider.defaultAuthType ?? 'APIKey'}</span>
|
||||
{provider.defaultBaseUrl && <span>{provider.defaultBaseUrl}</span>}
|
||||
<span>{provider.source ?? 'gateway'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +168,7 @@ export function ProviderManagementPanel(props: {
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteProvider(provider)}>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => setPendingDeleteProvider(provider)}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
@@ -213,6 +222,16 @@ export function ProviderManagementPanel(props: {
|
||||
图标 URL
|
||||
<Input value={form.iconPath} onChange={(event) => setForm({ ...form, iconPath: event.target.value })} placeholder="https://static.51easyai.com/xxx.png" />
|
||||
</Label>
|
||||
<Label className="spanTwo">
|
||||
默认 Base URL
|
||||
<Input value={form.defaultBaseUrl} onChange={(event) => setForm({ ...form, defaultBaseUrl: event.target.value })} placeholder="https://api.example.com/v1" />
|
||||
</Label>
|
||||
<Label>
|
||||
默认授权类型
|
||||
<Select value={form.defaultAuthType} onChange={(event) => setForm({ ...form, defaultAuthType: event.target.value })}>
|
||||
{authTypeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
来源
|
||||
<Input value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} />
|
||||
@@ -224,6 +243,15 @@ export function ProviderManagementPanel(props: {
|
||||
</Select>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除厂商"
|
||||
description="删除模型厂商后不可恢复,请确认没有仍需保留的模型目录数据。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeleteProvider)}
|
||||
title={`确认删除模型厂商 ${pendingDeleteProvider?.displayName ?? ''}?`}
|
||||
onCancel={() => setPendingDeleteProvider(null)}
|
||||
onConfirm={() => pendingDeleteProvider ? deleteProvider(pendingDeleteProvider) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -247,6 +275,8 @@ function providerPayload(form: ProviderForm, current?: CatalogProvider): Catalog
|
||||
displayName: form.displayName.trim(),
|
||||
providerType: form.providerType.trim() || 'openai',
|
||||
iconPath: form.iconPath.trim(),
|
||||
defaultBaseUrl: form.defaultBaseUrl.trim(),
|
||||
defaultAuthType: form.defaultAuthType.trim() || 'APIKey',
|
||||
source: form.source.trim() || 'gateway',
|
||||
status: form.status,
|
||||
capabilitySchema: current?.capabilitySchema ?? {},
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Gauge, Pencil, Plus, RotateCcw, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type RuntimePolicyForm = {
|
||||
policyKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rpm: string;
|
||||
tpm: string;
|
||||
concurrency: string;
|
||||
retryEnabled: boolean;
|
||||
retryMaxAttempts: string;
|
||||
retryAllowKeywords: string;
|
||||
retryDenyKeywords: string;
|
||||
autoDisableEnabled: boolean;
|
||||
autoDisableThreshold: string;
|
||||
autoDisableKeywords: string;
|
||||
degradeEnabled: boolean;
|
||||
degradeCooldownSeconds: string;
|
||||
degradeKeywords: string;
|
||||
metadataJson: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function RuntimePoliciesPanel(props: {
|
||||
message: string;
|
||||
runtimePolicySets: RuntimePolicySet[];
|
||||
state: LoadState;
|
||||
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
|
||||
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<RuntimePolicyForm>(() => createDefaultForm());
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingDeletePolicy, setPendingDeletePolicy] = useState<RuntimePolicySet | null>(null);
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(createDefaultForm(`runtime-${Date.now().toString(36)}`));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editPolicy(policy: RuntimePolicySet) {
|
||||
setEditingId(policy.id);
|
||||
setLocalError('');
|
||||
setForm(policyToForm(policy));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingId('');
|
||||
setLocalError('');
|
||||
setForm(createDefaultForm());
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveRuntimePolicySet(formToPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '运行策略保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePolicy(policy: RuntimePolicySet) {
|
||||
if (isDefaultPolicy(policy)) return;
|
||||
try {
|
||||
await props.onDeleteRuntimePolicySet(policy.id);
|
||||
setPendingDeletePolicy(null);
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '运行策略删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>运行策略</CardTitle>
|
||||
<p className="mutedText">统一维护限流、重试、自动禁用和优先级降级关键词;基准模型可绑定默认策略,模型可覆盖其中某几项。</p>
|
||||
</div>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增策略
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="runtimePolicyGrid">
|
||||
{props.runtimePolicySets.map((policy) => (
|
||||
<article className="runtimePolicyCard" key={policy.id}>
|
||||
<header>
|
||||
<div className="iconBox"><ShieldCheck size={18} /></div>
|
||||
<div>
|
||||
<strong>{policy.name}</strong>
|
||||
<span>{policy.policyKey}</span>
|
||||
</div>
|
||||
<Badge variant={policy.status === 'active' ? 'success' : 'secondary'}>{policy.status}</Badge>
|
||||
</header>
|
||||
{policy.description && <p>{policy.description}</p>}
|
||||
<div className="runtimePolicySummary">
|
||||
<span><Gauge size={13} />{rateLimitSummary(policy)}</span>
|
||||
<span>{retrySummary(policy)}</span>
|
||||
<span>{autoDisableSummary(policy)}</span>
|
||||
<span>{degradeSummary(policy)}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editPolicy(policy)}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDefaultPolicy(policy)}
|
||||
title={isDefaultPolicy(policy) ? '默认运行策略不能删除' : undefined}
|
||||
onClick={() => setPendingDeletePolicy(policy)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
bodyClassName="runtimePolicyFormBody"
|
||||
className="runtimePolicyDialog"
|
||||
eyebrow={editingId ? 'Edit Runtime Policy' : 'New Runtime Policy'}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
<RotateCcw size={15} />
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
|
||||
{editingId ? '保存修改' : '新增策略'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑运行策略' : '新增运行策略'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>策略 Key<Input value={form.policyKey} onChange={(event) => setForm({ ...form, policyKey: event.target.value })} /></Label>
|
||||
<Label>名称<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">描述<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>限流策略</strong><span>TPM / RPM / 并发</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Label>RPM / 分钟请求<Input value={form.rpm} inputMode="numeric" onChange={(event) => setForm({ ...form, rpm: event.target.value })} /></Label>
|
||||
<Label>TPM / 分钟 Token<Input value={form.tpm} inputMode="numeric" onChange={(event) => setForm({ ...form, tpm: event.target.value })} /></Label>
|
||||
<Label>并发请求<Input value={form.concurrency} inputMode="numeric" onChange={(event) => setForm({ ...form, concurrency: event.target.value })} /></Label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>重试策略</strong><span>允许/拒绝关键词控制是否继续尝试下一个客户端</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Toggle checked={form.retryEnabled} label="允许失败重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
|
||||
<Label>最大尝试次数<Input value={form.retryMaxAttempts} inputMode="numeric" onChange={(event) => setForm({ ...form, retryMaxAttempts: event.target.value })} /></Label>
|
||||
<KeywordField label="允许重试关键词" value={form.retryAllowKeywords} onChange={(value) => setForm({ ...form, retryAllowKeywords: value })} />
|
||||
<KeywordField label="拒绝重试关键词" value={form.retryDenyKeywords} onChange={(value) => setForm({ ...form, retryDenyKeywords: value })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="runtimePolicySection spanTwo">
|
||||
<header><strong>禁用与降级</strong><span>自动禁用错误关键词、优先级降级关键词</span></header>
|
||||
<div className="runtimePolicyRows">
|
||||
<Toggle checked={form.autoDisableEnabled} label="启用自动禁用" onChange={(checked) => setForm({ ...form, autoDisableEnabled: checked })} />
|
||||
<Label>禁用触发次数<Input value={form.autoDisableThreshold} inputMode="numeric" onChange={(event) => setForm({ ...form, autoDisableThreshold: event.target.value })} /></Label>
|
||||
<KeywordField label="自动禁用关键词" value={form.autoDisableKeywords} onChange={(value) => setForm({ ...form, autoDisableKeywords: value })} />
|
||||
<Toggle checked={form.degradeEnabled} label="启用优先级降级" onChange={(checked) => setForm({ ...form, degradeEnabled: checked })} />
|
||||
<Label>降级冷却秒数<Input value={form.degradeCooldownSeconds} inputMode="numeric" onChange={(event) => setForm({ ...form, degradeCooldownSeconds: event.target.value })} /></Label>
|
||||
<KeywordField label="降级关键词" value={form.degradeKeywords} onChange={(value) => setForm({ ...form, degradeKeywords: value })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Label>状态<Input value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">元数据 JSON<Textarea value={form.metadataJson} rows={4} onChange={(event) => setForm({ ...form, metadataJson: event.target.value })} /></Label>
|
||||
</FormDialog>
|
||||
<ConfirmDialog
|
||||
confirmLabel="删除策略"
|
||||
description="已绑定模型会清空策略绑定,删除后不可恢复。"
|
||||
loading={props.state === 'loading'}
|
||||
open={Boolean(pendingDeletePolicy)}
|
||||
title={`确认删除运行策略 ${pendingDeletePolicy?.name ?? ''}?`}
|
||||
onCancel={() => setPendingDeletePolicy(null)}
|
||||
onConfirm={() => pendingDeletePolicy ? deletePolicy(pendingDeletePolicy) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="platformToggle">
|
||||
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
|
||||
<span><strong>{props.label}</strong></span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordField(props: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Label className="spanTwo">
|
||||
{props.label}
|
||||
<Input value={props.value} placeholder="多个关键词用逗号或换行分隔" onChange={(event) => props.onChange(event.target.value)} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm {
|
||||
return {
|
||||
policyKey,
|
||||
name: policyKey === 'default-runtime-v1' ? '默认运行策略' : '',
|
||||
description: '',
|
||||
rpm: '120',
|
||||
tpm: '240000',
|
||||
concurrency: '6',
|
||||
retryEnabled: true,
|
||||
retryMaxAttempts: '2',
|
||||
retryAllowKeywords: 'rate_limit, timeout, server_error, network, 429, 5xx',
|
||||
retryDenyKeywords: 'invalid_api_key, insufficient_quota, billing_not_active, permission_denied',
|
||||
autoDisableEnabled: false,
|
||||
autoDisableThreshold: '3',
|
||||
autoDisableKeywords: 'invalid_api_key, account_deactivated, permission_denied, billing_not_active',
|
||||
degradeEnabled: true,
|
||||
degradeCooldownSeconds: '300',
|
||||
degradeKeywords: 'rate_limit, quota, timeout, temporarily_unavailable, overloaded',
|
||||
metadataJson: '{}',
|
||||
status: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
|
||||
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
|
||||
const retry = readObject(policy.retryPolicy);
|
||||
const disable = readObject(policy.autoDisablePolicy);
|
||||
const degrade = readObject(policy.degradePolicy);
|
||||
return {
|
||||
policyKey: policy.policyKey,
|
||||
name: policy.name,
|
||||
description: policy.description ?? '',
|
||||
rpm: String(readRateLimit(rateRules, 'rpm') || ''),
|
||||
tpm: String(readRateLimit(rateRules, 'tpm_total') || ''),
|
||||
concurrency: String(readRateLimit(rateRules, 'concurrent') || ''),
|
||||
retryEnabled: readBool(retry.enabled, true),
|
||||
retryMaxAttempts: String(readNumber(retry.maxAttempts, 2)),
|
||||
retryAllowKeywords: stringifyKeywords(retry.allowKeywords),
|
||||
retryDenyKeywords: stringifyKeywords(retry.denyKeywords),
|
||||
autoDisableEnabled: readBool(disable.enabled, false),
|
||||
autoDisableThreshold: String(readNumber(disable.threshold, 3)),
|
||||
autoDisableKeywords: stringifyKeywords(disable.keywords),
|
||||
degradeEnabled: readBool(degrade.enabled, true),
|
||||
degradeCooldownSeconds: String(readNumber(degrade.cooldownSeconds, 300)),
|
||||
degradeKeywords: stringifyKeywords(degrade.keywords),
|
||||
metadataJson: JSON.stringify(policy.metadata ?? {}, null, 2),
|
||||
status: policy.status || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
|
||||
return {
|
||||
policyKey: form.policyKey.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
rateLimitPolicy: { rules: rateLimitRules(form) },
|
||||
retryPolicy: {
|
||||
enabled: form.retryEnabled,
|
||||
maxAttempts: positiveInt(form.retryMaxAttempts, 2),
|
||||
allowKeywords: parseKeywords(form.retryAllowKeywords),
|
||||
denyKeywords: parseKeywords(form.retryDenyKeywords),
|
||||
},
|
||||
autoDisablePolicy: {
|
||||
enabled: form.autoDisableEnabled,
|
||||
threshold: positiveInt(form.autoDisableThreshold, 3),
|
||||
keywords: parseKeywords(form.autoDisableKeywords),
|
||||
},
|
||||
degradePolicy: {
|
||||
enabled: form.degradeEnabled,
|
||||
cooldownSeconds: positiveInt(form.degradeCooldownSeconds, 300),
|
||||
keywords: parseKeywords(form.degradeKeywords),
|
||||
},
|
||||
metadata: parseJson(form.metadataJson),
|
||||
status: form.status.trim() || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimitRules(form: RuntimePolicyForm) {
|
||||
return [
|
||||
limitRule('rpm', form.rpm),
|
||||
limitRule('tpm_total', form.tpm),
|
||||
limitRule('concurrent', form.concurrency, 60, 120),
|
||||
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number) {
|
||||
const limit = Number(value);
|
||||
if (!Number.isFinite(limit) || limit <= 0) return undefined;
|
||||
return { metric, limit, windowSeconds, leaseTtlSeconds };
|
||||
}
|
||||
|
||||
function isDefaultPolicy(policy: RuntimePolicySet) {
|
||||
return policy.policyKey === 'default-runtime-v1';
|
||||
}
|
||||
|
||||
function rateLimitSummary(policy: RuntimePolicySet) {
|
||||
const rules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
|
||||
const rpm = readRateLimit(rules, 'rpm') || '-';
|
||||
const tpm = readRateLimit(rules, 'tpm_total') || '-';
|
||||
const concurrent = readRateLimit(rules, 'concurrent') || '-';
|
||||
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}`;
|
||||
}
|
||||
|
||||
function retrySummary(policy: RuntimePolicySet) {
|
||||
const retry = readObject(policy.retryPolicy);
|
||||
return readBool(retry.enabled, false) ? `重试 ${readNumber(retry.maxAttempts, 2)} 次` : '不重试';
|
||||
}
|
||||
|
||||
function autoDisableSummary(policy: RuntimePolicySet) {
|
||||
const disable = readObject(policy.autoDisablePolicy);
|
||||
return readBool(disable.enabled, false) ? `自动禁用: ${stringifyKeywords(disable.keywords) || '-'}` : '自动禁用关闭';
|
||||
}
|
||||
|
||||
function degradeSummary(policy: RuntimePolicySet) {
|
||||
const degrade = readObject(policy.degradePolicy);
|
||||
return readBool(degrade.enabled, false) ? `降级: ${stringifyKeywords(degrade.keywords) || '-'}` : '降级关闭';
|
||||
}
|
||||
|
||||
function readRateLimit(rules: unknown[], metric: string) {
|
||||
const rule = rules.find((item) => readObject(item).metric === metric);
|
||||
return readNumber(readObject(rule).limit, 0);
|
||||
}
|
||||
|
||||
function parseKeywords(value: string) {
|
||||
return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function stringifyKeywords(value: unknown) {
|
||||
return Array.isArray(value) ? value.map(String).join(', ') : '';
|
||||
}
|
||||
|
||||
function positiveInt(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readBool(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readObject(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error('元数据 JSON 必须是对象');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
export type CapabilityEditorState = {
|
||||
types: string[];
|
||||
flags: Record<CapabilityFlagKey, boolean>;
|
||||
limits: Record<CapabilityLimitKey, string>;
|
||||
options: Record<CapabilityOptionKey, string>;
|
||||
typeConfigs: Record<string, Record<string, unknown>>;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CapabilityFlagKey =
|
||||
| 'stream'
|
||||
| 'vision'
|
||||
| 'reasoning'
|
||||
| 'multimodal'
|
||||
| 'supportTool'
|
||||
| 'supportThinking'
|
||||
| 'supportThinkingModeSwitch'
|
||||
| 'supportStructuredOutput'
|
||||
| 'supportWebSearch'
|
||||
| 'inputMultipleImages'
|
||||
| 'outputMultipleImages'
|
||||
| 'supportBase64Input'
|
||||
| 'supportUrlInput'
|
||||
| 'outputAudio';
|
||||
|
||||
export type CapabilityLimitKey =
|
||||
| 'maxContextTokens'
|
||||
| 'maxInputTokens'
|
||||
| 'maxOutputTokens'
|
||||
| 'maxThinkingTokens'
|
||||
| 'inputMaxImagesCount'
|
||||
| 'outputMaxImagesCount'
|
||||
| 'outputMaxSize'
|
||||
| 'durationMin'
|
||||
| 'durationMax';
|
||||
|
||||
export type CapabilityOptionKey = 'outputResolutions' | 'aspectRatios' | 'thinkingEfforts' | 'embeddingDimensions';
|
||||
|
||||
export type CapabilityConfigArea = 'text' | 'embedding' | 'image' | 'video' | 'audio' | 'digitalHuman' | 'model3d' | 'none';
|
||||
|
||||
export type PlatformModelTypeDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
area: CapabilityConfigArea;
|
||||
};
|
||||
|
||||
export const platformModelTypeDefinitions: PlatformModelTypeDefinition[] = [
|
||||
{ key: 'text_generate', label: '文本生成', group: '文本', area: 'text' },
|
||||
{ key: 'text_embedding', label: '文本向量', group: '文本', area: 'embedding' },
|
||||
{ key: 'image_generate', label: '图像生成', group: '图像', area: 'image' },
|
||||
{ key: 'image_edit', label: '图像编辑', group: '图像', area: 'image' },
|
||||
{ key: 'image_analysis', label: '图像理解', group: '理解', area: 'text' },
|
||||
{ key: 'video_generate', label: '视频生成', group: '视频', area: 'video' },
|
||||
{ key: 'image_to_video', label: '图生视频', group: '视频', area: 'video' },
|
||||
{ key: 'video_edit', label: '视频编辑', group: '视频', area: 'video' },
|
||||
{ key: 'omni_video', label: '多模态视频', group: '视频', area: 'video' },
|
||||
{ key: 'video_understanding', label: '视频理解', group: '理解', area: 'text' },
|
||||
{ key: 'audio_understanding', label: '音频理解', group: '理解', area: 'text' },
|
||||
{ key: 'omni', label: '全模态理解', group: '理解', area: 'text' },
|
||||
{ key: 'tools_call', label: '工具调用', group: '文本', area: 'text' },
|
||||
{ key: 'audio_generate', label: '音频生成', group: '音频', area: 'audio' },
|
||||
{ key: 'text_to_speech', label: '语音合成', group: '音频', area: 'audio' },
|
||||
{ key: 'speech_to_text', label: '语音转写', group: '音频', area: 'audio' },
|
||||
{ key: 'digital_human_generate', label: '数字人生成', group: '数字人', area: 'digitalHuman' },
|
||||
{ key: 'text_to_model', label: '文生 3D', group: '3D', area: 'model3d' },
|
||||
{ key: 'image_to_model', label: '图生 3D', group: '3D', area: 'model3d' },
|
||||
{ key: 'multiview_to_model', label: '多视角 3D', group: '3D', area: 'model3d' },
|
||||
{ key: 'mesh_edit', label: '3D 模型编辑', group: '3D', area: 'model3d' },
|
||||
];
|
||||
|
||||
const modelTypeDefinitionMap = new Map(platformModelTypeDefinitions.map((item) => [item.key, item]));
|
||||
|
||||
const flagKeys: CapabilityFlagKey[] = [
|
||||
'stream',
|
||||
'vision',
|
||||
'reasoning',
|
||||
'multimodal',
|
||||
'supportTool',
|
||||
'supportThinking',
|
||||
'supportThinkingModeSwitch',
|
||||
'supportStructuredOutput',
|
||||
'supportWebSearch',
|
||||
'inputMultipleImages',
|
||||
'outputMultipleImages',
|
||||
'supportBase64Input',
|
||||
'supportUrlInput',
|
||||
'outputAudio',
|
||||
];
|
||||
|
||||
const limitKeys: CapabilityLimitKey[] = [
|
||||
'maxContextTokens',
|
||||
'maxInputTokens',
|
||||
'maxOutputTokens',
|
||||
'maxThinkingTokens',
|
||||
'inputMaxImagesCount',
|
||||
'outputMaxImagesCount',
|
||||
'outputMaxSize',
|
||||
'durationMin',
|
||||
'durationMax',
|
||||
];
|
||||
|
||||
const optionKeys: CapabilityOptionKey[] = ['outputResolutions', 'aspectRatios', 'thinkingEfforts', 'embeddingDimensions'];
|
||||
const managedRootKeys = new Set<string>([
|
||||
'originalTypes',
|
||||
'stream',
|
||||
'vision',
|
||||
'reasoning',
|
||||
'multimodal',
|
||||
'supportTool',
|
||||
'supportThinking',
|
||||
'supportThinkingModeSwitch',
|
||||
'supportStructuredOutput',
|
||||
'supportWebSearch',
|
||||
'supportBase64Input',
|
||||
'supportUrlInput',
|
||||
'maxContextTokens',
|
||||
'maxInputTokens',
|
||||
'maxOutputTokens',
|
||||
'maxThinkingTokens',
|
||||
'thinkingEffortLevels',
|
||||
]);
|
||||
|
||||
const managedNestedKeys = new Set<string>([
|
||||
'supportTool',
|
||||
'supportThinking',
|
||||
'supportThinkingModeSwitch',
|
||||
'supportStructuredOutput',
|
||||
'supportWebSearch',
|
||||
'thinkingEffortLevels',
|
||||
'max_context_tokens',
|
||||
'max_input_tokens',
|
||||
'max_output_tokens',
|
||||
'max_thinking_tokens',
|
||||
'dimensions',
|
||||
'support_base64_input',
|
||||
'support_url_input',
|
||||
'input_multiple_images',
|
||||
'input_max_images_count',
|
||||
'output_multiple_images',
|
||||
'output_max_images_count',
|
||||
'output_max_size',
|
||||
'output_resolutions',
|
||||
'aspect_ratio_allowed',
|
||||
'aspect_ratio_range',
|
||||
'input_aspect_ratio_range',
|
||||
'duration_range',
|
||||
'output_audio',
|
||||
]);
|
||||
|
||||
export function createCapabilityEditorState(modelType = 'text_generate'): CapabilityEditorState {
|
||||
return {
|
||||
types: [modelType].filter(Boolean),
|
||||
flags: Object.fromEntries(flagKeys.map((key) => [key, false])) as Record<CapabilityFlagKey, boolean>,
|
||||
limits: Object.fromEntries(limitKeys.map((key) => [key, ''])) as Record<CapabilityLimitKey, string>,
|
||||
options: Object.fromEntries(optionKeys.map((key) => [key, ''])) as Record<CapabilityOptionKey, string>,
|
||||
typeConfigs: modelType ? { [modelType]: defaultCapabilityConfig(modelType) } : {},
|
||||
extra: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function modelTypeLabel(type: string) {
|
||||
return modelTypeDefinitionMap.get(type)?.label ?? type;
|
||||
}
|
||||
|
||||
export function modelTypeGroup(type: string) {
|
||||
return modelTypeDefinitionMap.get(type)?.group ?? '其他';
|
||||
}
|
||||
|
||||
export function capabilityAreas(types: string[]) {
|
||||
const areas = new Set(types.map((type) => modelTypeDefinitionMap.get(type)?.area ?? inferArea(type)));
|
||||
return {
|
||||
hasText: areas.has('text'),
|
||||
hasEmbedding: areas.has('embedding'),
|
||||
hasImage: areas.has('image'),
|
||||
hasVideo: areas.has('video'),
|
||||
hasAudio: areas.has('audio'),
|
||||
hasDigitalHuman: areas.has('digitalHuman'),
|
||||
hasModel3d: areas.has('model3d'),
|
||||
};
|
||||
}
|
||||
|
||||
export function capabilitiesToForm(value?: Record<string, unknown>, modelType = 'text_generate'): CapabilityEditorState {
|
||||
const state = createCapabilityEditorState(modelType);
|
||||
const source = isRecord(value) ? value : {};
|
||||
const sourceTypes = stringArray(source.originalTypes);
|
||||
const nestedTypes = Object.entries(source)
|
||||
.filter(([key, item]) => key !== 'originalTypes' && isRecord(item) && looksLikeCapabilityType(key))
|
||||
.map(([key]) => key);
|
||||
state.types = uniqueStrings([...sourceTypes, ...nestedTypes, modelType]);
|
||||
state.typeConfigs = Object.fromEntries(state.types.map((type) => [type, readCapabilityConfig(source, type, modelType)]));
|
||||
state.extra = Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => !managedRootKeys.has(key) && !state.types.includes(key)),
|
||||
);
|
||||
|
||||
state.flags.stream = boolFrom(source.stream);
|
||||
state.flags.vision = boolFrom(source.vision);
|
||||
state.flags.reasoning = boolFrom(source.reasoning);
|
||||
state.flags.multimodal = boolFrom(source.multimodal);
|
||||
state.flags.supportTool = boolFrom(source.supportTool ?? nestedValue(source, 'supportTool'));
|
||||
state.flags.supportThinking = boolFrom(source.supportThinking ?? nestedValue(source, 'supportThinking'));
|
||||
state.flags.supportThinkingModeSwitch = boolFrom(source.supportThinkingModeSwitch ?? nestedValue(source, 'supportThinkingModeSwitch'));
|
||||
state.flags.supportStructuredOutput = boolFrom(source.supportStructuredOutput ?? nestedValue(source, 'supportStructuredOutput'));
|
||||
state.flags.supportWebSearch = boolFrom(source.supportWebSearch ?? nestedValue(source, 'supportWebSearch'));
|
||||
state.flags.supportBase64Input = boolFrom(source.supportBase64Input ?? nestedValue(source, 'support_base64_input'));
|
||||
state.flags.supportUrlInput = boolFrom(source.supportUrlInput ?? nestedValue(source, 'support_url_input'));
|
||||
state.flags.inputMultipleImages = nestedBool(source, 'input_multiple_images');
|
||||
state.flags.outputMultipleImages = nestedBool(source, 'output_multiple_images');
|
||||
state.flags.outputAudio = nestedBool(source, 'output_audio');
|
||||
|
||||
state.limits.maxContextTokens = stringValue(source.maxContextTokens ?? source.max_context_tokens ?? nestedValue(source, 'max_context_tokens'));
|
||||
state.limits.maxInputTokens = stringValue(source.maxInputTokens ?? source.max_input_tokens ?? nestedValue(source, 'max_input_tokens'));
|
||||
state.limits.maxOutputTokens = stringValue(source.maxOutputTokens ?? source.max_output_tokens ?? nestedValue(source, 'max_output_tokens'));
|
||||
state.limits.maxThinkingTokens = stringValue(source.maxThinkingTokens ?? source.max_thinking_tokens ?? nestedValue(source, 'max_thinking_tokens'));
|
||||
state.limits.inputMaxImagesCount = stringValue(nestedValue(source, 'input_max_images_count'));
|
||||
state.limits.outputMaxImagesCount = stringValue(nestedValue(source, 'output_max_images_count'));
|
||||
state.limits.outputMaxSize = stringValue(nestedValue(source, 'output_max_size'));
|
||||
|
||||
const durationRange = nestedValue(source, 'duration_range');
|
||||
if (Array.isArray(durationRange)) {
|
||||
state.limits.durationMin = stringValue(durationRange[0]);
|
||||
state.limits.durationMax = stringValue(durationRange[1]);
|
||||
}
|
||||
state.options.outputResolutions = stringArray(nestedValue(source, 'output_resolutions')).join(', ');
|
||||
state.options.aspectRatios = stringArray(nestedValue(source, 'aspect_ratio_allowed')).join(', ');
|
||||
state.options.thinkingEfforts = stringArray(source.thinkingEffortLevels ?? nestedValue(source, 'thinkingEffortLevels')).join(', ');
|
||||
state.options.embeddingDimensions = stringArray(nestedValue(source, 'dimensions')).join(', ');
|
||||
return state;
|
||||
}
|
||||
|
||||
export function syncPrimaryCapability(state: CapabilityEditorState, modelType: string): CapabilityEditorState {
|
||||
if (!modelType || state.types.includes(modelType)) return state;
|
||||
return addCapabilityType(state, modelType);
|
||||
}
|
||||
|
||||
export function addCapabilityType(state: CapabilityEditorState, type: string): CapabilityEditorState {
|
||||
if (!type) return state;
|
||||
return {
|
||||
...state,
|
||||
types: uniqueStrings([...state.types, type]),
|
||||
typeConfigs: {
|
||||
...state.typeConfigs,
|
||||
[type]: state.typeConfigs[type] ?? defaultCapabilityConfig(type),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCapabilityType(state: CapabilityEditorState, type: string): CapabilityEditorState {
|
||||
const nextTypes = state.types.filter((item) => item !== type);
|
||||
if (!nextTypes.length) return state;
|
||||
const nextConfigs = { ...state.typeConfigs };
|
||||
delete nextConfigs[type];
|
||||
return { ...state, types: nextTypes, typeConfigs: nextConfigs };
|
||||
}
|
||||
|
||||
export function updateCapabilityConfig(state: CapabilityEditorState, type: string, config: Record<string, unknown>): CapabilityEditorState {
|
||||
return { ...state, typeConfigs: { ...state.typeConfigs, [type]: config } };
|
||||
}
|
||||
|
||||
export function capabilitiesFromForm(state: CapabilityEditorState): Record<string, unknown> {
|
||||
const output: Record<string, unknown> = { ...state.extra, originalTypes: uniqueStrings(state.types) };
|
||||
state.types.forEach((type) => {
|
||||
const config = normalizeCapabilityConfig(type, state.typeConfigs[type] ?? defaultCapabilityConfig(type));
|
||||
if (Object.keys(config).length > 0) output[type] = config;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
export function defaultCapabilityConfig(type: string): Record<string, unknown> {
|
||||
if (isTextLike(type)) {
|
||||
return {
|
||||
supportTool: type === 'tools_call',
|
||||
supportThinking: false,
|
||||
supportThinkingModeSwitch: false,
|
||||
supportStructuredOutput: false,
|
||||
supportWebSearch: false,
|
||||
};
|
||||
}
|
||||
if (type === 'text_embedding') return { dimensions: [] };
|
||||
if (type === 'image_generate') {
|
||||
return { output_resolutions: ['1K'], output_multiple_images: false };
|
||||
}
|
||||
if (type === 'image_edit') {
|
||||
return { input_multiple_images: false, output_resolutions: ['1K'], output_multiple_images: false };
|
||||
}
|
||||
if (type === 'video_generate') {
|
||||
return { output_resolutions: ['720p'], duration_range: [5, 10], output_audio: false };
|
||||
}
|
||||
if (type === 'image_to_video') {
|
||||
return {
|
||||
output_resolutions: ['720p'],
|
||||
input_first_frame: true,
|
||||
input_first_last_frame: false,
|
||||
input_last_frame: false,
|
||||
input_reference_generate_single: false,
|
||||
input_reference_generate_multiple: false,
|
||||
support_video_effect_template: false,
|
||||
output_audio: false,
|
||||
aspect_ratio_allowed: [],
|
||||
};
|
||||
}
|
||||
if (type === 'video_edit') {
|
||||
return { input_video_required: true, output_resolutions: ['720p'], duration_range: [5, 10], output_audio: false };
|
||||
}
|
||||
if (type === 'omni_video') {
|
||||
return {
|
||||
supported_modes: ['text_to_video'],
|
||||
output_resolutions: ['720p'],
|
||||
aspect_ratio_allowed: ['16:9'],
|
||||
duration_options: [5],
|
||||
output_audio: false,
|
||||
max_videos: 0,
|
||||
max_images: 0,
|
||||
max_elements: 0,
|
||||
};
|
||||
}
|
||||
if (type === 'text_to_model' || type === 'image_to_model' || type === 'multiview_to_model') {
|
||||
return { support_texture: false, support_part_generation: false };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function cleanNestedConfig(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([key, item]) => !managedNestedKeys.has(key) || shouldKeepNestedValue(key, item)));
|
||||
}
|
||||
|
||||
function readCapabilityConfig(source: Record<string, unknown>, type: string, modelType: string) {
|
||||
const nested = isRecord(source[type]) ? source[type] : {};
|
||||
const root = type === modelType ? rootCompatibilityConfig(source) : {};
|
||||
const hasSourceConfig = Object.keys(root).length > 0 || Object.keys(nested).length > 0;
|
||||
const base = hasSourceConfig ? {} : defaultCapabilityConfig(type);
|
||||
return normalizeCapabilityConfig(type, { ...base, ...root, ...nested });
|
||||
}
|
||||
|
||||
function rootCompatibilityConfig(source: Record<string, unknown>) {
|
||||
const config: Record<string, unknown> = {};
|
||||
[
|
||||
'stream',
|
||||
'vision',
|
||||
'reasoning',
|
||||
'multimodal',
|
||||
'supportTool',
|
||||
'supportThinking',
|
||||
'supportThinkingModeSwitch',
|
||||
'supportStructuredOutput',
|
||||
'supportWebSearch',
|
||||
'supportBase64Input',
|
||||
'supportUrlInput',
|
||||
'maxContextTokens',
|
||||
'maxInputTokens',
|
||||
'maxOutputTokens',
|
||||
'maxThinkingTokens',
|
||||
'thinkingEffortLevels',
|
||||
].forEach((key) => {
|
||||
if (key in source) config[toCapabilityKey(key)] = source[key];
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
function toCapabilityKey(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
supportBase64Input: 'support_base64_input',
|
||||
supportUrlInput: 'support_url_input',
|
||||
maxContextTokens: 'max_context_tokens',
|
||||
maxInputTokens: 'max_input_tokens',
|
||||
maxOutputTokens: 'max_output_tokens',
|
||||
maxThinkingTokens: 'max_thinking_tokens',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function normalizeCapabilityConfig(type: string, config: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(config).filter(([key, value]) => shouldKeepConfigValue(type, key, value)));
|
||||
}
|
||||
|
||||
function shouldKeepConfigValue(type: string, key: string, value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
if (Array.isArray(value)) return value.length > 0 || defaultArrayKeys(type).has(key);
|
||||
if (isRecord(value)) return Object.keys(value).length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function defaultArrayKeys(type: string) {
|
||||
if (type === 'text_embedding') return new Set(['dimensions']);
|
||||
if (type === 'image_to_video' || type === 'omni_video') return new Set(['aspect_ratio_allowed']);
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
function shouldKeepNestedValue(key: string, value: unknown) {
|
||||
if (key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'aspect_ratio_range' || key === 'input_aspect_ratio_range' || key === 'duration_range') {
|
||||
return !Array.isArray(value);
|
||||
}
|
||||
if (key === 'thinkingEffortLevels' || key === 'dimensions') {
|
||||
return !Array.isArray(value);
|
||||
}
|
||||
if (key.endsWith('_count') || key === 'output_max_size' || key.endsWith('_tokens')) {
|
||||
return typeof value !== 'number' && typeof value !== 'string';
|
||||
}
|
||||
return typeof value !== 'boolean' && value !== 'true' && value !== 'false';
|
||||
}
|
||||
|
||||
function nestedValue(source: Record<string, unknown>, key: string) {
|
||||
for (const type of stringArray(source.originalTypes)) {
|
||||
const config = isRecord(source[type]) ? source[type] : undefined;
|
||||
if (config && key in config) return config[key];
|
||||
}
|
||||
for (const value of Object.values(source)) {
|
||||
if (isRecord(value) && key in value) return value[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nestedBool(source: Record<string, unknown>, key: string) {
|
||||
return boolFrom(nestedValue(source, key));
|
||||
}
|
||||
|
||||
function setIfTruthy(target: Record<string, unknown>, key: string, value: boolean) {
|
||||
if (value) target[key] = true;
|
||||
}
|
||||
|
||||
function setBoolean(target: Record<string, unknown>, key: string, value: boolean) {
|
||||
target[key] = value;
|
||||
}
|
||||
|
||||
function setNumberIfPresent(target: Record<string, unknown>, key: string, value: string) {
|
||||
const parsed = Number(value);
|
||||
if (value.trim() && Number.isFinite(parsed)) target[key] = parsed;
|
||||
}
|
||||
|
||||
function setArrayIfPresent(target: Record<string, unknown>, key: string, value: string) {
|
||||
const items = value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
if (items.length) target[key] = items;
|
||||
}
|
||||
|
||||
function setNumberArrayIfPresent(target: Record<string, unknown>, key: string, value: string) {
|
||||
const items = value.split(',').map((item) => Number(item.trim())).filter(Number.isFinite);
|
||||
if (items.length) target[key] = items;
|
||||
}
|
||||
|
||||
function durationRange(min: string, max: string) {
|
||||
const parsedMin = Number(min);
|
||||
const parsedMax = Number(max);
|
||||
if (!min.trim() && !max.trim()) return undefined;
|
||||
return [Number.isFinite(parsedMin) ? parsedMin : 0, Number.isFinite(parsedMax) ? parsedMax : parsedMin || 0];
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(String).filter(Boolean);
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]) {
|
||||
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function boolFrom(value: unknown) {
|
||||
return value === true || value === 'true';
|
||||
}
|
||||
|
||||
function looksLikeCapabilityType(key: string) {
|
||||
return modelTypeDefinitionMap.has(key) || key.includes('_') || ['chat', 'image', 'video', 'audio', 'embedding', 'music', 'digital_human', 'model_3d'].includes(key);
|
||||
}
|
||||
|
||||
function inferArea(type: string): CapabilityConfigArea {
|
||||
if (isTextLike(type)) return 'text';
|
||||
if (type === 'text_embedding') return 'embedding';
|
||||
if (isImageLike(type)) return 'image';
|
||||
if (isVideoLike(type)) return 'video';
|
||||
if (type.includes('audio') || type.includes('speech')) return 'audio';
|
||||
if (type.includes('digital_human')) return 'digitalHuman';
|
||||
if (type.includes('model') || type.includes('mesh')) return 'model3d';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function isTextLike(type: string) {
|
||||
return ['text_generate', 'image_analysis', 'video_understanding', 'audio_understanding', 'omni', 'tools_call'].includes(type);
|
||||
}
|
||||
|
||||
function isImageLike(type: string) {
|
||||
return type.includes('image') || type === 'image';
|
||||
}
|
||||
|
||||
function isVideoLike(type: string) {
|
||||
return type.includes('video') || type === 'video';
|
||||
}
|
||||
|
||||
function isVisualMedia(type: string) {
|
||||
return isImageLike(type) || isVideoLike(type);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && !Array.isArray(value) && typeof value === 'object';
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { BaseModelCatalogItem } from '@easyai-ai-gateway/contracts';
|
||||
import type { PlatformCreateInput, PlatformModelBindingInput } from '../../types';
|
||||
|
||||
export const authTypes = [
|
||||
{ value: 'APIKey', label: 'API Key', description: 'apiKey 字段,兼容 OpenAI / Gemini' },
|
||||
{ value: 'Token', label: 'Token', description: 'token 字段,适合 Bearer Token 类平台' },
|
||||
{ value: 'AccessKey-SecretKey', label: 'AccessKey + SecretKey', description: 'accessKey / secretKey 双字段' },
|
||||
{ value: 'none', label: '无授权', description: '仅用于内网或测试模式' },
|
||||
];
|
||||
|
||||
export interface PlatformWizardForm {
|
||||
provider: string;
|
||||
platformKey: string;
|
||||
name: string;
|
||||
internalName: string;
|
||||
baseUrl: string;
|
||||
authType: string;
|
||||
credentialsPreview: Record<string, unknown>;
|
||||
apiKey: string;
|
||||
token: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
pricingRuleSetId: string;
|
||||
defaultDiscountFactor: string;
|
||||
priority: string;
|
||||
retryEnabled: boolean;
|
||||
retryMaxAttempts: string;
|
||||
rpmLimit: string;
|
||||
rpsLimit: string;
|
||||
tpmLimit: string;
|
||||
concurrencyLimit: string;
|
||||
testMode: boolean;
|
||||
supportBase64Input: boolean;
|
||||
supportUrlInput: boolean;
|
||||
modelDiscountFactor: string;
|
||||
modelDiscountFactors: Record<string, string>;
|
||||
modelOverrideRetry: boolean;
|
||||
modelRetryEnabled: boolean;
|
||||
modelRetryMaxAttempts: string;
|
||||
modelOverrideRateLimit: boolean;
|
||||
modelRpmLimit: string;
|
||||
modelRpsLimit: string;
|
||||
modelTpmLimit: string;
|
||||
modelConcurrencyLimit: string;
|
||||
selectionMode: 'all' | 'partial';
|
||||
selectedModelIds: string[];
|
||||
}
|
||||
|
||||
export interface ProviderConnectionDefaults {
|
||||
defaultAuthType?: string;
|
||||
defaultBaseUrl?: string;
|
||||
}
|
||||
|
||||
export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnectionDefaults): PlatformWizardForm {
|
||||
return {
|
||||
provider,
|
||||
platformKey: '',
|
||||
name: '',
|
||||
internalName: '',
|
||||
baseUrl: defaults?.defaultBaseUrl ?? '',
|
||||
authType: defaults?.defaultAuthType ?? 'APIKey',
|
||||
credentialsPreview: {},
|
||||
apiKey: '',
|
||||
token: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
pricingRuleSetId: '',
|
||||
defaultDiscountFactor: '1',
|
||||
priority: '100',
|
||||
retryEnabled: true,
|
||||
retryMaxAttempts: '2',
|
||||
rpmLimit: '',
|
||||
rpsLimit: '',
|
||||
tpmLimit: '',
|
||||
concurrencyLimit: '',
|
||||
testMode: false,
|
||||
supportBase64Input: true,
|
||||
supportUrlInput: true,
|
||||
modelDiscountFactor: '',
|
||||
modelDiscountFactors: {},
|
||||
modelOverrideRetry: false,
|
||||
modelRetryEnabled: true,
|
||||
modelRetryMaxAttempts: '2',
|
||||
modelOverrideRateLimit: false,
|
||||
modelRpmLimit: '',
|
||||
modelRpsLimit: '',
|
||||
modelTpmLimit: '',
|
||||
modelConcurrencyLimit: '',
|
||||
selectionMode: 'partial',
|
||||
selectedModelIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyProviderDefaults(form: PlatformWizardForm, provider: string, defaults?: ProviderConnectionDefaults): PlatformWizardForm {
|
||||
return {
|
||||
...form,
|
||||
provider,
|
||||
baseUrl: defaults?.defaultBaseUrl ?? '',
|
||||
authType: defaults?.defaultAuthType ?? 'APIKey',
|
||||
selectedModelIds: [],
|
||||
modelDiscountFactors: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function modelsForProvider(models: BaseModelCatalogItem[], provider: string) {
|
||||
return models.filter((item) => item.providerKey === provider && item.status !== 'hidden');
|
||||
}
|
||||
|
||||
export function selectedModelsForForm(models: BaseModelCatalogItem[], form: PlatformWizardForm) {
|
||||
const visibleModels = models.filter((item) => item.status !== 'hidden');
|
||||
if (form.selectionMode === 'all') {
|
||||
return visibleModels;
|
||||
}
|
||||
const selectedIds = new Set(form.selectedModelIds);
|
||||
return visibleModels.filter((item) => selectedIds.has(item.id));
|
||||
}
|
||||
|
||||
export function platformPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}): PlatformCreateInput {
|
||||
const credentials = credentialsPayload(form, options);
|
||||
return {
|
||||
provider: form.provider,
|
||||
platformKey: optionalString(form.platformKey),
|
||||
name: form.name.trim(),
|
||||
internalName: optionalString(form.internalName),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
authType: form.authType,
|
||||
credentials,
|
||||
config: {
|
||||
testMode: form.testMode,
|
||||
supportBase64Input: form.supportBase64Input,
|
||||
supportUrlInput: form.supportUrlInput,
|
||||
source: 'gateway-admin',
|
||||
},
|
||||
retryPolicy: {
|
||||
enabled: form.retryEnabled,
|
||||
maxAttempts: form.retryEnabled ? positiveInt(form.retryMaxAttempts, 2) : 1,
|
||||
},
|
||||
rateLimitPolicy: rateLimitPolicyPayload(form),
|
||||
defaultPricingMode: 'inherit_discount',
|
||||
defaultDiscountFactor: positiveNumber(form.defaultDiscountFactor, 1),
|
||||
pricingRuleSetId: optionalString(form.pricingRuleSetId),
|
||||
priority: positiveInt(form.priority, 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function platformModelPayloads(models: BaseModelCatalogItem[], form: PlatformWizardForm): PlatformModelBindingInput[] {
|
||||
return selectedModelsForForm(models, form).map((model) => ({
|
||||
baseModelId: model.id,
|
||||
canonicalModelKey: model.canonicalModelKey,
|
||||
modelName: model.providerModelName,
|
||||
modelAlias: stableModelAlias(model),
|
||||
modelType: primaryBaseModelType(model),
|
||||
displayName: stableModelAlias(model) || model.providerModelName,
|
||||
pricingMode: 'inherit_discount',
|
||||
discountFactor: optionalPositiveNumber(form.modelDiscountFactors[model.id]) ?? optionalPositiveNumber(form.modelDiscountFactor),
|
||||
retryPolicy: form.modelOverrideRetry
|
||||
? { enabled: form.modelRetryEnabled, maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1 }
|
||||
: undefined,
|
||||
rateLimitPolicy: form.modelOverrideRateLimit ? rateLimitPolicyPayload({
|
||||
rpmLimit: form.modelRpmLimit,
|
||||
rpsLimit: form.modelRpsLimit,
|
||||
tpmLimit: form.modelTpmLimit,
|
||||
concurrencyLimit: form.modelConcurrencyLimit,
|
||||
}) : undefined,
|
||||
runtimePolicyOverride: platformModelRuntimeOverride(form),
|
||||
}));
|
||||
}
|
||||
|
||||
export function stableModelAlias(model: BaseModelCatalogItem) {
|
||||
return stripProviderPrefix(
|
||||
readString(model.modelAlias) ||
|
||||
readString(model.displayName) ||
|
||||
readString(model.metadata?.alias) ||
|
||||
readString(readRecord(model.metadata?.rawModel)?.alias) ||
|
||||
model.providerModelName ||
|
||||
model.canonicalModelKey,
|
||||
model.providerKey,
|
||||
model.canonicalModelKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function baseModelTypes(model: BaseModelCatalogItem) {
|
||||
if (Array.isArray(model.modelType)) return model.modelType.map(String).filter(Boolean);
|
||||
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
|
||||
if (Array.isArray(values)) return values.map(String).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
export function baseModelTypeText(model: BaseModelCatalogItem) {
|
||||
return baseModelTypes(model).join(', ');
|
||||
}
|
||||
|
||||
export function primaryBaseModelType(model: BaseModelCatalogItem) {
|
||||
return baseModelTypes(model)[0] ?? 'text_generate';
|
||||
}
|
||||
|
||||
function stripProviderPrefix(value: string, providerKey: string, canonicalModelKey: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const prefix = `${providerKey}:`;
|
||||
if (providerKey && trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
|
||||
if (trimmed === canonicalModelKey) {
|
||||
const [, alias] = trimmed.split(/:(.*)/s);
|
||||
return alias || trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function platformModelRuntimeOverride(form: PlatformWizardForm) {
|
||||
const override: Record<string, unknown> = {};
|
||||
if (form.modelOverrideRetry) {
|
||||
override.retryPolicy = {
|
||||
enabled: form.modelRetryEnabled,
|
||||
maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1,
|
||||
};
|
||||
}
|
||||
if (form.modelOverrideRateLimit) {
|
||||
override.rateLimitPolicy = rateLimitPolicyPayload({
|
||||
rpmLimit: form.modelRpmLimit,
|
||||
rpsLimit: form.modelRpsLimit,
|
||||
tpmLimit: form.modelTpmLimit,
|
||||
concurrencyLimit: form.modelConcurrencyLimit,
|
||||
});
|
||||
}
|
||||
return Object.keys(override).length ? override : undefined;
|
||||
}
|
||||
|
||||
export function validatePlatformForm(form: PlatformWizardForm, selectedCount: number, options: { allowEmptyCredentials?: boolean } = {}): string {
|
||||
if (!form.provider.trim()) return '请选择模型厂商。';
|
||||
if (!form.name.trim()) return '请填写平台名称。';
|
||||
if (!form.baseUrl.trim()) return '请填写 Base URL。';
|
||||
if (options.allowEmptyCredentials && !hasCredentialInput(form)) {
|
||||
if (selectedCount === 0) return '请至少添加一个模型。';
|
||||
return '';
|
||||
}
|
||||
if (form.authType === 'APIKey' && !form.apiKey.trim() && !form.testMode) return '请填写 API Key,或开启测试模式。';
|
||||
if (form.authType === 'Token' && !form.token.trim() && !form.testMode) return '请填写 Token,或开启测试模式。';
|
||||
if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) {
|
||||
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
|
||||
}
|
||||
if (selectedCount === 0) return '请至少添加一个模型。';
|
||||
return '';
|
||||
}
|
||||
|
||||
export function providerLabel(provider: string) {
|
||||
return provider || 'provider';
|
||||
}
|
||||
|
||||
function credentialsPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}) {
|
||||
if (form.authType === 'APIKey') {
|
||||
return credentialPayloadForFields(form, options, [{ key: 'apiKey', value: form.apiKey, previewKeys: ['apiKey', 'api_key', 'key'] }]);
|
||||
}
|
||||
if (form.authType === 'Token') {
|
||||
return credentialPayloadForFields(form, options, [{ key: 'token', value: form.token, previewKeys: ['token'] }]);
|
||||
}
|
||||
if (form.authType === 'AccessKey-SecretKey') {
|
||||
return credentialPayloadForFields(form, options, [
|
||||
{ key: 'accessKey', value: form.accessKey, previewKeys: ['accessKey', 'access_key'] },
|
||||
{ key: 'secretKey', value: form.secretKey, previewKeys: ['secretKey', 'secret_key'] },
|
||||
]);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function credentialPayloadForFields(
|
||||
form: PlatformWizardForm,
|
||||
options: { preserveEmptyCredentials?: boolean },
|
||||
fields: Array<{ key: string; previewKeys: string[]; value: string }>,
|
||||
) {
|
||||
const entries = fields.map((field) => ({
|
||||
key: field.key,
|
||||
preview: credentialPreviewValue(form.credentialsPreview, ...field.previewKeys),
|
||||
value: field.value.trim(),
|
||||
}));
|
||||
const allUnchanged = entries.every((entry) => entry.preview && entry.value === entry.preview);
|
||||
if (options.preserveEmptyCredentials && allUnchanged) return undefined;
|
||||
const allEmpty = entries.every((entry) => !entry.value);
|
||||
if (allEmpty) return {};
|
||||
const payloadEntries: Array<[string, string | null]> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.preview && entry.value === entry.preview) continue;
|
||||
payloadEntries.push([entry.key, entry.value || null]);
|
||||
}
|
||||
return Object.fromEntries(payloadEntries);
|
||||
}
|
||||
|
||||
function hasCredentialInput(form: PlatformWizardForm) {
|
||||
if (form.authType === 'APIKey') return Boolean(form.apiKey.trim());
|
||||
if (form.authType === 'Token') return Boolean(form.token.trim());
|
||||
if (form.authType === 'AccessKey-SecretKey') return Boolean(form.accessKey.trim() || form.secretKey.trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
function credentialPreviewValue(preview: Record<string, unknown> | undefined, ...keys: string[]) {
|
||||
if (!preview) return '';
|
||||
for (const key of keys) {
|
||||
const value = preview[key];
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rpsLimit' | 'tpmLimit' | 'concurrencyLimit'>) {
|
||||
const rules = [
|
||||
limitRule('rpm', form.rpmLimit),
|
||||
limitRule('rps', form.rpsLimit, 1),
|
||||
limitRule('tpm_total', form.tpmLimit),
|
||||
limitRule('concurrent', form.concurrencyLimit),
|
||||
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
|
||||
return rules.length ? { rules } : {};
|
||||
}
|
||||
|
||||
function limitRule(metric: string, value: string, windowSeconds = 60) {
|
||||
const limit = positiveNumber(value, 0);
|
||||
if (!limit) return undefined;
|
||||
return {
|
||||
metric,
|
||||
limit,
|
||||
windowSeconds,
|
||||
leaseTtlSeconds: metric === 'concurrent' ? 120 : 70,
|
||||
};
|
||||
}
|
||||
|
||||
function optionalString(value: string) {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function positiveNumber(value: string, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function optionalPositiveNumber(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function positiveInt(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
+24
-1
@@ -1,9 +1,10 @@
|
||||
import type { AdminSection, ApiDocSection, PageKey, WorkspaceSection } from './types';
|
||||
import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection } from './types';
|
||||
|
||||
export interface AppRouteState {
|
||||
activePage: PageKey;
|
||||
adminSection: AdminSection;
|
||||
apiDocSection: ApiDocSection;
|
||||
playgroundMode: PlaygroundMode;
|
||||
workspaceSection: WorkspaceSection;
|
||||
}
|
||||
|
||||
@@ -11,6 +12,7 @@ export const defaultRouteState: AppRouteState = {
|
||||
activePage: 'home',
|
||||
adminSection: 'overview',
|
||||
apiDocSection: 'chat',
|
||||
playgroundMode: 'chat',
|
||||
workspaceSection: 'overview',
|
||||
};
|
||||
|
||||
@@ -31,6 +33,7 @@ const adminPaths: Record<AdminSection, string> = {
|
||||
users: '/admin/users',
|
||||
userGroups: '/admin/user-groups',
|
||||
runtime: '/admin/runtime',
|
||||
accessRules: '/admin/access-rules',
|
||||
};
|
||||
|
||||
const docsPaths: Record<ApiDocSection, string> = {
|
||||
@@ -41,13 +44,23 @@ const docsPaths: Record<ApiDocSection, string> = {
|
||||
files: '/docs/files',
|
||||
};
|
||||
|
||||
const playgroundPaths: Record<PlaygroundMode, string> = {
|
||||
chat: '/playground/chat',
|
||||
image: '/playground/image',
|
||||
video: '/playground/video',
|
||||
};
|
||||
|
||||
const workspaceSections = reverseMap(workspacePaths);
|
||||
const adminSections = reverseMap(adminPaths);
|
||||
const docsSections = reverseMap(docsPaths);
|
||||
const playgroundSections = reverseMap(playgroundPaths);
|
||||
|
||||
export function parseAppRoute(pathname = window.location.pathname): AppRouteState {
|
||||
const path = normalizePath(pathname);
|
||||
if (path === '/') return { ...defaultRouteState };
|
||||
if (path.startsWith('/playground')) {
|
||||
return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path) };
|
||||
}
|
||||
if (path === '/models') return { ...defaultRouteState, activePage: 'models' };
|
||||
if (path.startsWith('/workspace')) {
|
||||
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path) };
|
||||
@@ -62,6 +75,7 @@ export function parseAppRoute(pathname = window.location.pathname): AppRouteStat
|
||||
}
|
||||
|
||||
export function pathForPage(page: PageKey, route: AppRouteState): string {
|
||||
if (page === 'playground') return pathForPlaygroundMode(route.playgroundMode);
|
||||
if (page === 'models') return '/models';
|
||||
if (page === 'workspace') return pathForWorkspaceSection(route.workspaceSection);
|
||||
if (page === 'admin') return pathForAdminSection(route.adminSection);
|
||||
@@ -81,6 +95,10 @@ export function pathForApiDocSection(section: ApiDocSection) {
|
||||
return docsPaths[section] ?? docsPaths.chat;
|
||||
}
|
||||
|
||||
export function pathForPlaygroundMode(mode: PlaygroundMode) {
|
||||
return playgroundPaths[mode] ?? playgroundPaths.chat;
|
||||
}
|
||||
|
||||
function parseWorkspaceSection(path: string): WorkspaceSection {
|
||||
return workspaceSections[path] ?? (path === '/workspace' ? 'overview' : 'overview');
|
||||
}
|
||||
@@ -100,6 +118,11 @@ function parseDocSection(path: string): ApiDocSection {
|
||||
return docsSections[path] ?? 'chat';
|
||||
}
|
||||
|
||||
function parsePlaygroundMode(path: string): PlaygroundMode {
|
||||
if (path === '/playground') return 'chat';
|
||||
return playgroundSections[path] ?? 'chat';
|
||||
}
|
||||
|
||||
function normalizePath(pathname: string) {
|
||||
const path = pathname.replace(/\/+$/, '');
|
||||
return path || '/';
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
.baseCapabilityEditor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.baseCapabilityEditor > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.baseCapabilityEditor > header strong {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.baseCapabilityEditor > header span,
|
||||
.capabilityFormPanel > header small,
|
||||
.capabilityListItem small,
|
||||
.capabilityFormRow small,
|
||||
.capabilityPreserveNote span {
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.baseCapabilityEditor > header span,
|
||||
.capabilityFormPanel > header strong,
|
||||
.capabilityFormPanel > header small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.capabilityWorkbench {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.capabilitySidebar,
|
||||
.capabilityFormPanel,
|
||||
.capabilityFormGroup {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.capabilitySidebarTitle,
|
||||
.capabilityFormGroup > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilityList,
|
||||
.capabilityAddBox,
|
||||
.capabilityFormRows {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capabilityListItem {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.capabilityListItem[data-active="true"] {
|
||||
border-color: var(--ring);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px var(--ring);
|
||||
}
|
||||
|
||||
.capabilityListItem span,
|
||||
.capabilityFormRow > div,
|
||||
.capabilityPreserveNote {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.capabilityListItem strong,
|
||||
.capabilityListItem small,
|
||||
.capabilityFormRow strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capabilityFormRow small {
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.capabilityListItem strong,
|
||||
.capabilityFormRow strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilityAddBox {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.capabilitySummary {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 86px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.capabilitySummary strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilitySummary p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.capabilityFormPanel {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.capabilityFormPanel > header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.capabilityFormPanel > header strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.capabilityFormPanel > header span {
|
||||
width: fit-content;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilityFormGroup {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.capabilityFormGroup > header {
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.capabilityFormRows {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.capabilityFormRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 148px) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.capabilitySwitch {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilityBooleanSelect {
|
||||
width: fit-content;
|
||||
min-width: 154px;
|
||||
}
|
||||
|
||||
.capabilityRange {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capabilityScopedControl,
|
||||
.capabilityScopedRows {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capabilitySegmented {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.capabilitySegmented button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--control-radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilitySegmented button[data-active="true"] {
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
box-shadow: 0 1px 2px rgba(24, 24, 27, 0.08);
|
||||
}
|
||||
|
||||
.capabilitySegmented button:disabled {
|
||||
color: var(--text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.capabilityScopedRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr) 30px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.capabilityMultiValue,
|
||||
.capabilityMultiOptions,
|
||||
.capabilityCustomValue {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.capabilityMultiValue {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilityMultiOptions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilityCheckboxOption {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.capabilityCustomValue {
|
||||
flex: 1 1 210px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.capabilityCustomValue .shInput {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.capabilityAddValueButton {
|
||||
min-width: 64px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capabilityPreserveNote {
|
||||
padding: 8px;
|
||||
border: 1px dashed #dce2ea;
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.capabilityWorkbench,
|
||||
.capabilityFormRow,
|
||||
.capabilityFormPanel > header,
|
||||
.capabilityScopedRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -69,13 +69,13 @@
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #344054;
|
||||
color: var(--text-normal);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.docsGroup button[data-active="true"] {
|
||||
background: #f5f6f8;
|
||||
color: #111827;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.docsArticle {
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
.docsLead {
|
||||
margin: 18px 0 24px;
|
||||
color: #475467;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
gap: 12px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #475467;
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
.landingCopy p {
|
||||
max-width: 660px;
|
||||
color: #475467;
|
||||
color: var(--text-soft);
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
background: var(--surface-subtle);
|
||||
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
.previewHeader {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 900;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.previewGrid {
|
||||
@@ -103,7 +103,7 @@
|
||||
.previewGrid span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.previewGrid strong {
|
||||
@@ -113,10 +113,10 @@
|
||||
.previewFlow {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #111827;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.previewFlow span {
|
||||
|
||||
+932
-20
File diff suppressed because it is too large
Load Diff
+137
-19
@@ -44,8 +44,8 @@
|
||||
min-height: 56px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8ecf1;
|
||||
background: #fafbfc;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-subtle);
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -54,12 +54,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #111827;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pricingRuleItems svg {
|
||||
color: #6b7280;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.pricingRuleItems em {
|
||||
@@ -118,14 +118,95 @@
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: #475467;
|
||||
color: var(--text-soft);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.pricingModeTabs button.active {
|
||||
border-bottom-color: #111827;
|
||||
color: #111827;
|
||||
border-bottom-color: var(--primary);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.pricingModeWorkbench {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pricingModeSidebar,
|
||||
.pricingModePanel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pricingModeSidebarTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.pricingModeList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pricingModeList button {
|
||||
display: grid;
|
||||
min-height: 44px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pricingModeList button[data-active="true"] {
|
||||
border-color: var(--ring);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px var(--ring);
|
||||
}
|
||||
|
||||
.pricingModeList span,
|
||||
.pricingModeSummary {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pricingModeList strong,
|
||||
.pricingModeSummary strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.pricingModeList small,
|
||||
.pricingModeSummary p {
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.pricingModeSummary {
|
||||
min-height: 86px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.pricingModeSummary p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.pricingModeRule,
|
||||
@@ -179,6 +260,41 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pricingFormRows {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pricingFormRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(112px, 148px) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.pricingFormRow > strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pricingFormReadonly {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.pricingModeInlineInput,
|
||||
.pricingInlineField {
|
||||
display: grid;
|
||||
@@ -195,10 +311,10 @@
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
color: #344054;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-normal);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -215,7 +331,7 @@
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: #111827;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -225,15 +341,15 @@
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f6f7f9;
|
||||
color: #344054;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.pricingFormulaBox p {
|
||||
margin-top: 8px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.pricingFormulaBox input {
|
||||
@@ -251,7 +367,7 @@
|
||||
.pricingWeightTable header {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
background: #f6f7f9;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.pricingWeightRows {
|
||||
@@ -279,10 +395,10 @@
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
color: #344054;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-normal);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.pricingInlineAdd {
|
||||
@@ -291,11 +407,13 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.pricingRuleGrid,
|
||||
.pricingModeWorkbench,
|
||||
.pricingModeBaseRows,
|
||||
.pricingRuleFormBody,
|
||||
.pricingModeInlineRows,
|
||||
.pricingModeInlineInput,
|
||||
.pricingInlineField,
|
||||
.pricingFormRow,
|
||||
.pricingTextPriceGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
+329
-75
@@ -1,104 +1,276 @@
|
||||
.shCard {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.shCardHeader,
|
||||
.shCardContent,
|
||||
.shCardFooter {
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.shCardHeader {
|
||||
display: flex;
|
||||
min-height: 60px;
|
||||
min-height: 54px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #eef1f4;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.shCardTitle {
|
||||
font-size: 16px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.shCardDescription,
|
||||
.mutedText {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shFormItem {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.shFormItemLabel {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.shFormItemDescription {
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.shButton {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
font-weight: 800;
|
||||
border-radius: var(--control-radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
|
||||
white-space: nowrap;
|
||||
transition: background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.shButtonDefaultSize {
|
||||
padding: 0 14px;
|
||||
.shButtonXs {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.shButtonSm {
|
||||
min-height: 34px;
|
||||
padding: 0 11px;
|
||||
font-size: 13px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shButtonMd,
|
||||
.shButtonDefaultSize {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shButtonLg {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.shButtonXl {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.shButtonIcon {
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
width: var(--control-height);
|
||||
min-height: var(--control-height);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.shButtonDefault {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
|
||||
.shButtonDefault:hover {
|
||||
background: #202734;
|
||||
background: #27272a;
|
||||
}
|
||||
|
||||
.shButtonSecondary {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.shButtonOutline {
|
||||
border-color: var(--border);
|
||||
border-color: var(--input);
|
||||
background: #fff;
|
||||
color: #344054;
|
||||
color: var(--text-normal);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.shButtonOutline:hover,
|
||||
.shButtonSecondary:hover {
|
||||
border-color: #d5dbe3;
|
||||
background: #f9fafb;
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.shButtonGhost {
|
||||
background: transparent;
|
||||
color: #344054;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.shButtonGhost:hover {
|
||||
background: #f5f6f8;
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.shButtonDestructive {
|
||||
background: var(--destructive);
|
||||
color: #fff;
|
||||
color: var(--destructive-foreground);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.screenMessageViewport {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.screenMessage {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.screenMessageIcon {
|
||||
display: inline-flex;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.screenMessageText {
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.screenMessageClose {
|
||||
width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.screenMessage-error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.screenMessage-error .screenMessageIcon {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.screenMessage-success {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.screenMessage-success .screenMessageIcon {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.confirmDialogBackdrop {
|
||||
position: fixed;
|
||||
z-index: 110;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(16, 19, 24, 0.36);
|
||||
}
|
||||
|
||||
.confirmDialog {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
width: min(420px, 100%);
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
color: var(--text-normal);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.confirmDialogIcon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.confirmDialogBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.confirmDialogBody strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.confirmDialogBody p {
|
||||
margin-top: 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.confirmDialogActions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.formDialogBackdrop {
|
||||
@@ -118,9 +290,9 @@
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 22px 60px rgba(16, 24, 40, 0.16);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.formDialogHeader {
|
||||
@@ -128,22 +300,24 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.formDialogHeader span {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formDialogHeader strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 18px;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.formDialogForm {
|
||||
@@ -158,7 +332,7 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.formDialogActions {
|
||||
@@ -168,7 +342,7 @@
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 -1px 0 rgba(16, 24, 40, 0.02);
|
||||
}
|
||||
|
||||
@@ -176,28 +350,102 @@
|
||||
.shTextarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--foreground);
|
||||
border-radius: var(--control-radius);
|
||||
background: transparent;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: 1.25;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
outline: none;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.shInput {
|
||||
min-height: 40px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.shSelect {
|
||||
appearance: none;
|
||||
padding-right: 30px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--text-soft) 50%),
|
||||
linear-gradient(135deg, var(--text-soft) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) 50%,
|
||||
calc(100% - 11px) 50%;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.shTextarea {
|
||||
min-height: 96px;
|
||||
padding: 10px 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.shInput:focus,
|
||||
.shTextarea:focus,
|
||||
.tokenInline input:focus {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.14);
|
||||
.shControlXs {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.shControlSm {
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shControlMd {
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.shControlLg {
|
||||
min-height: 40px;
|
||||
padding: 0 11px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.shControlXl {
|
||||
min-height: 44px;
|
||||
padding: 0 13px;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.shSelect.shControlXs { padding-right: 26px; }
|
||||
.shSelect.shControlSm { padding-right: 28px; }
|
||||
.shSelect.shControlMd { padding-right: 30px; }
|
||||
.shSelect.shControlLg { padding-right: 32px; }
|
||||
.shSelect.shControlXl { padding-right: 34px; }
|
||||
|
||||
.shTextareaXs { min-height: 64px; padding: 8px 9px; font-size: var(--font-size-xs); }
|
||||
.shTextareaSm { min-height: 78px; padding: 9px 10px; font-size: var(--font-size-sm); }
|
||||
.shTextareaMd { min-height: 96px; padding: 10px 11px; font-size: var(--font-size-base); }
|
||||
.shTextareaLg { min-height: 112px; padding: 11px 12px; font-size: var(--font-size-base); }
|
||||
.shTextareaXl { min-height: 128px; padding: 12px 14px; font-size: var(--font-size-md); }
|
||||
|
||||
.shInput:focus-visible,
|
||||
.shTextarea:focus-visible,
|
||||
.tokenInline input:focus-visible {
|
||||
box-shadow: 0 0 0 1px var(--ring);
|
||||
}
|
||||
|
||||
.shButton:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--ring);
|
||||
}
|
||||
|
||||
.shInput::placeholder,
|
||||
.shTextarea::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.shInput:disabled,
|
||||
.shTextarea:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shLabel,
|
||||
@@ -207,9 +455,9 @@
|
||||
}
|
||||
|
||||
.shLabel {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
@@ -226,27 +474,28 @@
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #f6f7f9;
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.shTab {
|
||||
display: inline-flex;
|
||||
min-height: 34px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
border-radius: calc(var(--control-radius) - 1px);
|
||||
background: transparent;
|
||||
color: #475467;
|
||||
font-weight: 800;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.shTab[data-active="true"] {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
background: var(--surface);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.shBadge {
|
||||
@@ -257,13 +506,13 @@
|
||||
padding: 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.shBadgeDefault { background: #eef1f5; color: #202734; }
|
||||
.shBadgeSecondary { background: #f3f5f7; color: #5d6675; }
|
||||
.shBadgeOutline { border-color: var(--border); background: #fff; color: #344054; }
|
||||
.shBadgeDefault { background: #eef1f5; color: var(--text-normal); }
|
||||
.shBadgeSecondary { background: #f3f5f7; color: var(--text-soft); }
|
||||
.shBadgeOutline { border-color: var(--border); background: #fff; color: var(--text-normal); }
|
||||
.shBadgeSuccess { background: #edf7f1; color: #157a57; }
|
||||
.shBadgeWarning { background: #fbf4e8; color: #8a6116; }
|
||||
.shBadgeDestructive { background: #fff3f3; color: #b42318; }
|
||||
@@ -276,11 +525,11 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||
min-width: 520px;
|
||||
border-bottom: 1px solid #eef1f4;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.shTableHeader {
|
||||
background: #f7f8fa;
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.shTableHead,
|
||||
@@ -293,14 +542,14 @@
|
||||
}
|
||||
|
||||
.shTableHead {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
color: var(--text-soft);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.shTableCell {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
@@ -319,9 +568,9 @@
|
||||
.taskPreview pre {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #f7f8fa;
|
||||
color: #1f2937;
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-normal);
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -331,6 +580,11 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.createdApiKeyBox {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.codeBlock,
|
||||
.taskPreview pre {
|
||||
margin: 0;
|
||||
|
||||
+44
-2
@@ -1,7 +1,8 @@
|
||||
export type LoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
export type AuthMode = 'login' | 'register' | 'external';
|
||||
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
|
||||
export type PageKey = 'home' | 'models' | 'workspace' | 'admin' | 'docs';
|
||||
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
|
||||
export type PlaygroundMode = 'chat' | 'image' | 'video';
|
||||
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks';
|
||||
export type ApiDocSection = 'chat' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
|
||||
export type AdminSection =
|
||||
@@ -13,7 +14,8 @@ export type AdminSection =
|
||||
| 'tenants'
|
||||
| 'users'
|
||||
| 'userGroups'
|
||||
| 'runtime';
|
||||
| 'runtime'
|
||||
| 'accessRules';
|
||||
|
||||
export interface LoginForm {
|
||||
account: string;
|
||||
@@ -58,3 +60,43 @@ export interface PlatformModelForm {
|
||||
pricingRuleSetId: string;
|
||||
discountFactor: string;
|
||||
}
|
||||
|
||||
export interface PlatformCreateInput {
|
||||
provider: string;
|
||||
platformKey?: string;
|
||||
name: string;
|
||||
internalName?: string;
|
||||
baseUrl?: string;
|
||||
authType?: string;
|
||||
credentials?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
retryPolicy?: Record<string, unknown>;
|
||||
rateLimitPolicy?: Record<string, unknown>;
|
||||
defaultPricingMode?: string;
|
||||
defaultDiscountFactor?: number;
|
||||
pricingRuleSetId?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface PlatformModelBindingInput {
|
||||
canonicalModelKey?: string;
|
||||
baseModelId?: string;
|
||||
modelName: string;
|
||||
modelAlias?: string;
|
||||
modelType: string;
|
||||
displayName?: string;
|
||||
pricingMode?: string;
|
||||
retryPolicy?: Record<string, unknown>;
|
||||
rateLimitPolicy?: Record<string, unknown>;
|
||||
runtimePolicySetId?: string;
|
||||
runtimePolicyOverride?: Record<string, unknown>;
|
||||
pricingRuleSetId?: string;
|
||||
discountFactor?: number;
|
||||
}
|
||||
|
||||
export interface PlatformWithModelsInput {
|
||||
platformId?: string;
|
||||
platform: PlatformCreateInput;
|
||||
models: PlatformModelBindingInput[];
|
||||
selectionMode: 'all' | 'partial';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
port: 5178,
|
||||
},
|
||||
|
||||
Generated
+4707
-19
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
@@ -12,6 +12,7 @@ const shutdownGraceMs = Number(process.env.GO_WATCH_SHUTDOWN_GRACE_MS || 2500);
|
||||
const restartDelayMs = Number(process.env.GO_WATCH_RESTART_DELAY_MS || 200);
|
||||
const commandIndex = process.argv.indexOf('--');
|
||||
const command = commandIndex >= 0 ? process.argv.slice(commandIndex + 1) : ['go', 'run', './cmd/gateway'];
|
||||
const prestartCommand = process.env.GO_WATCH_PRESTART || '';
|
||||
const ignoredDirs = new Set([
|
||||
'.git',
|
||||
'.nx',
|
||||
@@ -21,7 +22,7 @@ const ignoredDirs = new Set([
|
||||
'tmp',
|
||||
'vendor',
|
||||
]);
|
||||
const watchedExtensions = new Set(['.go', '.mod', '.sum']);
|
||||
const watchedExtensions = new Set(['.go', '.mod', '.sum', '.sql']);
|
||||
|
||||
if (!command.length) {
|
||||
console.error('[go-watch] missing command after --');
|
||||
@@ -89,6 +90,19 @@ function start() {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (prestartCommand) {
|
||||
log(`prestart: ${prestartCommand}`);
|
||||
const result = spawnSync(prestartCommand, {
|
||||
cwd: watchRoot,
|
||||
env: process.env,
|
||||
shell: true,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
log(`prestart failed code=${result.status ?? 'null'} signal=${result.signal ?? 'null'}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log(`starting: ${command.join(' ')}`);
|
||||
child = spawn(command[0], command.slice(1), {
|
||||
cwd: watchRoot,
|
||||
|
||||
Reference in New Issue
Block a user