diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index c115a48..a4cce96 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -127,15 +127,16 @@ func TestOpenAIClientChatContract(t *testing.T) { Model: "openai:gpt-4o-mini", Body: map[string]any{"model": "openai:gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "ping"}}}, Candidate: store.RuntimeModelCandidate{ - BaseURL: server.URL, - ModelName: "gpt-4o-mini", - Credentials: map[string]any{"apiKey": "test-key"}, + BaseURL: server.URL, + ModelName: "gpt-4o-mini", + ProviderModelName: "openai-compatible-gpt-4o-mini", + Credentials: map[string]any{"apiKey": "test-key"}, }, }) if err != nil { t.Fatalf("run openai client: %v", err) } - if gotPath != "/chat/completions" || gotAuth != "Bearer test-key" || gotModel != "gpt-4o-mini" { + if gotPath != "/chat/completions" || gotAuth != "Bearer test-key" || gotModel != "openai-compatible-gpt-4o-mini" { t.Fatalf("unexpected request path=%s auth=%s model=%s", gotPath, gotAuth, gotModel) } if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" { @@ -231,16 +232,17 @@ func TestGeminiClientChatContract(t *testing.T) { "messages": []any{map[string]any{"role": "user", "content": "ping"}}, }, Candidate: store.RuntimeModelCandidate{ - BaseURL: server.URL, - ModelName: "gemini-2.5-flash", - ModelType: "chat", - Credentials: map[string]any{"apiKey": "gemini-key"}, + BaseURL: server.URL, + ModelName: "gemini-2.5-flash", + ProviderModelName: "gemini-compatible-2.5-flash", + ModelType: "chat", + Credentials: map[string]any{"apiKey": "gemini-key"}, }, }) if err != nil { t.Fatalf("run gemini client: %v", err) } - if gotPath != "/v1beta/models/gemini-2.5-flash:generateContent" || gotKey != "gemini-key" || gotText != "ping" { + if gotPath != "/v1beta/models/gemini-compatible-2.5-flash:generateContent" || gotKey != "gemini-key" || gotText != "ping" { t.Fatalf("unexpected request path=%s key=%s text=%s", gotPath, gotKey, gotText) } if response.Usage.TotalTokens != 10 || extractText(response.Result) != "gemini ok" { @@ -290,9 +292,10 @@ func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) { "image": "https://example.com/source.png", }, Candidate: store.RuntimeModelCandidate{ - BaseURL: server.URL, - ModelName: "doubao-seedream-4-0-250828", - Credentials: map[string]any{"apiKey": "volces-key"}, + BaseURL: server.URL, + ModelName: "豆包Seedream-4.0", + ProviderModelName: "doubao-seedream-4-0-250828", + Credentials: map[string]any{"apiKey": "volces-key"}, Capabilities: map[string]any{ "image_edit": map[string]any{"output_multiple_images": true}, }, @@ -366,9 +369,10 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) { "aspect_ratio": "16:9", }, Candidate: store.RuntimeModelCandidate{ - BaseURL: server.URL, - ModelName: "doubao-seedance-2-0-260128", - Credentials: map[string]any{"apiKey": "volces-key"}, + BaseURL: server.URL, + ModelName: "豆包Seedance-2.0", + ProviderModelName: "doubao-seedance-2-0-260128", + Credentials: map[string]any{"apiKey": "volces-key"}, PlatformConfig: map[string]any{ "volcesPollIntervalMs": 100, "volcesPollTimeoutSeconds": 1, diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index 8c6b0e5..fe01806 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -22,7 +22,7 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error } body := geminiBody(request) raw, _ := json.Marshal(body) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiURL(request.Candidate.BaseURL, request.Candidate.ModelName, apiKey), bytes.NewReader(raw)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey), bytes.NewReader(raw)) if err != nil { return Response{}, err } diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index 822c729..8dcd1a8 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -23,7 +23,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false} } body := cloneBody(request.Body) - body["model"] = request.Candidate.ModelName + body["model"] = upstreamModelName(request.Candidate) stream := request.Stream || boolValue(body, "stream") raw, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw)) diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 7c30f4a..8ddf62c 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" @@ -72,6 +73,13 @@ func IsRetryable(err error) bool { return errors.As(err, &clientErr) && clientErr.Retryable } +func upstreamModelName(candidate store.RuntimeModelCandidate) string { + if name := strings.TrimSpace(candidate.ProviderModelName); name != "" { + return name + } + return candidate.ModelName +} + func ErrorCode(err error) string { var clientErr *ClientError if errors.As(err, &clientErr) && clientErr.Code != "" { diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index acdbe18..acb967d 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -177,7 +177,7 @@ func (c VolcesClient) getJSON(ctx context.Context, baseURL string, path string, func volcesImageBody(request Request) map[string]any { body := cleanProviderBody(request.Body) - body["model"] = request.Candidate.ModelName + body["model"] = upstreamModelName(request.Candidate) if _, ok := body["watermark"]; !ok { body["watermark"] = false } @@ -200,7 +200,7 @@ func volcesImageBody(request Request) map[string]any { func volcesVideoBody(request Request) map[string]any { body := cleanProviderBody(request.Body) - body["model"] = request.Candidate.ModelName + body["model"] = upstreamModelName(request.Candidate) content := contentItems(body["content"]) if len(content) == 0 { content = buildVolcesContentFromBody(body) @@ -515,7 +515,7 @@ func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[st "id": upstreamTaskID, "object": "video.generation", "created": created, - "model": request.Candidate.ModelName, + "model": upstreamModelName(request.Candidate), "status": "succeeded", "upstream_task_id": upstreamTaskID, "data": data, diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 6d400fb..f382a3a 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -264,7 +264,7 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "create platform model failed") return } - writeJSON(w, http.StatusCreated, model) + writeJSON(w, http.StatusCreated, s.platformModelResponse(r.Context(), model)) } func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) { @@ -292,7 +292,7 @@ func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "replace platform models failed") return } - writeJSON(w, http.StatusOK, map[string]any{"items": models}) + writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)}) } func (s *Server) deletePlatformModel(w http.ResponseWriter, r *http.Request) { @@ -315,7 +315,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "list models failed") return } - writeJSON(w, http.StatusOK, map[string]any{"items": models}) + writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)}) } func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) { @@ -326,7 +326,7 @@ func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "list playable models failed") return } - writeJSON(w, http.StatusOK, map[string]any{"items": models}) + writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)}) } func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) { @@ -623,22 +623,88 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "unauthorized") return } - limit := 50 - if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { - parsed, err := strconv.Atoi(raw) - if err != nil || parsed <= 0 { - writeError(w, http.StatusBadRequest, "invalid limit") - return - } - limit = parsed + query := r.URL.Query() + page, err := positiveQueryInt(query.Get("page"), 1) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid page") + return } - tasks, err := s.store.ListTasks(r.Context(), user, limit) + pageSizeRaw := query.Get("pageSize") + if pageSizeRaw == "" { + pageSizeRaw = query.Get("limit") + } + pageSize, err := positiveQueryInt(pageSizeRaw, 50) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid pageSize") + return + } + createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdFrom") + return + } + createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdTo") + return + } + result, err := s.store.ListTasks(r.Context(), user, store.TaskListFilter{ + Query: firstNonEmpty(query.Get("q"), query.Get("query")), + ModelType: firstNonEmpty(query.Get("modelType"), query.Get("type")), + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Page: page, + PageSize: pageSize, + }) if err != nil { s.logger.Error("list tasks failed", "error", err) writeError(w, http.StatusInternalServerError, "list tasks failed") return } - writeJSON(w, http.StatusOK, map[string]any{"items": tasks}) + writeJSON(w, http.StatusOK, map[string]any{ + "items": result.Items, + "total": result.Total, + "page": result.Page, + "pageSize": result.PageSize, + }) +} + +func positiveQueryInt(raw string, fallback int) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("invalid positive integer") + } + return value, nil +} + +func parseTaskListTime(values ...string) (*time.Time, error) { + raw := strings.TrimSpace(firstNonEmpty(values...)) + if raw == "" { + return nil, nil + } + layouts := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"} + var lastErr error + for _, layout := range layouts { + parsed, err := time.ParseInLocation(layout, raw, time.Local) + if err == nil { + return &parsed, nil + } + lastErr = err + } + return nil, lastErr +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" } func boolValue(body map[string]any, key string) bool { diff --git a/apps/api/internal/httpapi/model_catalog.go b/apps/api/internal/httpapi/model_catalog.go new file mode 100644 index 0000000..c0a0991 --- /dev/null +++ b/apps/api/internal/httpapi/model_catalog.go @@ -0,0 +1,1446 @@ +package httpapi + +import ( + "net/http" + "sort" + "strconv" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type ModelCatalogResponse struct { + Items []ModelCatalogItem `json:"items"` + Filters ModelCatalogFilters `json:"filters"` + Summary ModelCatalogSummary `json:"summary"` +} + +type ModelCatalogSummary struct { + ModelCount int `json:"modelCount"` + SourceCount int `json:"sourceCount"` +} + +type ModelCatalogFilters struct { + Capabilities []ModelCatalogFilterOption `json:"capabilities"` + Providers []ModelCatalogFilterOption `json:"providers"` +} + +type ModelCatalogFilterOption struct { + Value string `json:"value"` + Label string `json:"label"` + Count int `json:"count"` + IconPath string `json:"iconPath,omitempty"` +} + +type ModelCatalogItem struct { + ID string `json:"id"` + Alias string `json:"alias"` + DisplayName string `json:"displayName"` + ModelName string `json:"modelName"` + Description string `json:"description,omitempty"` + IconPath string `json:"iconPath,omitempty"` + ModelType []string `json:"modelType"` + CapabilityTags []string `json:"capabilityTags"` + ProviderKeys []string `json:"providerKeys"` + Providers []ModelCatalogProviderSummary `json:"providers"` + SourceCount int `json:"sourceCount"` + Source ModelCatalogText `json:"source"` + Discount ModelCatalogText `json:"discount"` + RateLimits ModelCatalogRateLimits `json:"rateLimits"` + Permission ModelCatalogPermission `json:"permission"` + Pricing ModelCatalogPricing `json:"pricing"` + Enabled bool `json:"enabled"` + StatusLabel string `json:"statusLabel,omitempty"` + Sources []ModelCatalogSource `json:"sources,omitempty"` +} + +type ModelCatalogProviderSummary struct { + Key string `json:"key"` + Name string `json:"name"` + IconPath string `json:"iconPath,omitempty"` + SourceCount int `json:"sourceCount"` +} + +type ModelCatalogSource struct { + ID string `json:"id"` + PlatformID string `json:"platformId,omitempty"` + PlatformName string `json:"platformName,omitempty"` + ProviderKey string `json:"providerKey"` + ProviderName string `json:"providerName"` + ModelName string `json:"modelName"` + ModelAlias string `json:"modelAlias,omitempty"` + DisplayName string `json:"displayName"` + ModelType []string `json:"modelType"` + Enabled bool `json:"enabled"` + RateLimits ModelCatalogRateLimits `json:"rateLimits"` +} + +type ModelCatalogText struct { + Label string `json:"label"` + Title string `json:"title,omitempty"` +} + +type ModelCatalogPermission struct { + Label string `json:"label"` + Title string `json:"title,omitempty"` + AllowGroups []string `json:"allowGroups,omitempty"` + DenyGroups []string `json:"denyGroups,omitempty"` +} + +type ModelCatalogPricing struct { + Lines []string `json:"lines"` + Title string `json:"title,omitempty"` +} + +type ModelCatalogRateLimits struct { + RPM *float64 `json:"rpm,omitempty"` + TPM *float64 `json:"tpm,omitempty"` + Concurrent *float64 `json:"concurrent,omitempty"` + Label string `json:"label"` + Title string `json:"title,omitempty"` +} + +type catalogSource struct { + model store.PlatformModel + platform store.Platform + provider store.CatalogProvider + baseModel store.BaseModel + providerKey string + providerName string + description string + iconPath string + rateLimits ModelCatalogRateLimits +} + +type catalogGroup struct { + key string + alias string + displayName string + modelName string + modelType []string + capabilities map[string]any + sources []catalogSource + enabled bool +} + +func (s *Server) listModelCatalog(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + models, err := s.store.ListModels(ctx) + if err != nil { + s.logger.Error("list model catalog models failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + models = s.platformModelResponses(ctx, models) + + platforms, err := s.store.ListPlatforms(ctx) + if err != nil { + s.logger.Error("list model catalog platforms failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + providers, err := s.store.ListCatalogProviders(ctx) + if err != nil { + s.logger.Error("list model catalog providers failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + runtimePolicies, err := s.store.ListRuntimePolicySets(ctx) + if err != nil { + s.logger.Error("list model catalog runtime policies failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + accessRules, err := s.store.ListAccessRules(ctx) + if err != nil { + s.logger.Error("list model catalog access rules failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + userGroups, err := s.store.ListUserGroups(ctx) + if err != nil { + s.logger.Error("list model catalog user groups failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + baseModels, err := s.store.ListBaseModels(ctx) + if err != nil { + s.logger.Error("list model catalog base models failed", "error", err) + writeError(w, http.StatusInternalServerError, "list model catalog failed") + return + } + + writeJSON(w, http.StatusOK, buildModelCatalog(models, platforms, providers, runtimePolicies, accessRules, userGroups, baseModels)) +} + +func buildModelCatalog( + models []store.PlatformModel, + platforms []store.Platform, + providers []store.CatalogProvider, + runtimePolicies []store.RuntimePolicySet, + accessRules []store.AccessRule, + userGroups []store.UserGroup, + baseModels []store.BaseModel, +) ModelCatalogResponse { + platformMap := mapPlatforms(platforms) + providerMap := mapProviders(providers) + runtimePolicyMap := mapRuntimePolicies(runtimePolicies) + accessRuleGroups := mapAccessRuleGroups(accessRules, mapUserGroups(userGroups)) + baseModelMap := mapBaseModels(baseModels) + + groups := map[string]*catalogGroup{} + sourceCount := 0 + for _, model := range models { + platform := platformMap[model.PlatformID] + baseModel := baseModelMap[model.BaseModelID] + providerKey := modelProviderKey(model, baseModel, platform) + provider := providerMap[providerKey] + providerName := firstNonEmpty( + provider.DisplayName, + stringValue(baseModel.Metadata["sourceProviderName"]), + baseModel.ProviderKey, + model.Provider, + platform.Provider, + platform.Name, + model.PlatformName, + providerKey, + ) + description := modelDescription(model, baseModel) + iconPath := modelIconPath(model, baseModel, provider) + source := catalogSource{ + model: model, + platform: platform, + provider: provider, + baseModel: baseModel, + providerKey: providerKey, + providerName: providerName, + description: description, + iconPath: iconPath, + rateLimits: effectiveModelRateLimits(model, platform, runtimePolicyMap), + } + + key := catalogAliasKey(model) + current := groups[key] + if current == nil { + current = &catalogGroup{ + key: key, + alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName), + displayName: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName), + modelName: model.ModelName, + modelType: cloneStringSlice(model.ModelType), + capabilities: cloneObject(model.Capabilities), + } + groups[key] = current + } else { + current.modelType = uniqueStrings(append(current.modelType, model.ModelType...)) + current.capabilities = mergeCatalogObjects(current.capabilities, model.Capabilities) + } + sourceCount++ + current.sources = append(current.sources, source) + current.enabled = current.enabled || (model.Enabled && platform.Status == "enabled") + } + + items := make([]ModelCatalogItem, 0, len(groups)) + for _, group := range groups { + items = append(items, modelCatalogItem(group, accessRuleGroups)) + } + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(items[i].DisplayName) < strings.ToLower(items[j].DisplayName) + }) + + return ModelCatalogResponse{ + Items: items, + Filters: ModelCatalogFilters{ + Capabilities: modelCatalogCapabilityFilters(items), + Providers: modelCatalogProviderFilters(items), + }, + Summary: ModelCatalogSummary{ + ModelCount: len(items), + SourceCount: sourceCount, + }, + } +} + +func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRuleGroup) ModelCatalogItem { + sources := sortedCatalogSources(group.sources) + primary := sources[0] + providers := modelCatalogProviders(sources) + description := primary.description + if description == "" { + for _, source := range sources { + if source.description != "" { + description = source.description + break + } + } + } + iconPath := primary.iconPath + if iconPath == "" { + for _, source := range sources { + if source.iconPath != "" { + iconPath = source.iconPath + break + } + } + } + + itemSources := make([]ModelCatalogSource, 0, len(sources)) + for _, source := range sources { + itemSources = append(itemSources, ModelCatalogSource{ + ID: source.model.ID, + PlatformID: source.model.PlatformID, + PlatformName: firstNonEmpty(source.platform.Name, source.model.PlatformName), + ProviderKey: source.providerKey, + ProviderName: source.providerName, + ModelName: source.model.ModelName, + ModelAlias: source.model.ModelAlias, + DisplayName: firstNonEmpty(source.model.DisplayName, source.model.ModelAlias, source.model.ModelName), + ModelType: cloneStringSlice(source.model.ModelType), + Enabled: source.model.Enabled && source.platform.Status == "enabled", + RateLimits: source.rateLimits, + }) + } + + return ModelCatalogItem{ + ID: group.key, + Alias: group.alias, + DisplayName: group.displayName, + ModelName: group.modelName, + Description: description, + IconPath: iconPath, + ModelType: cloneStringSlice(group.modelType), + CapabilityTags: capabilityTagsForGroup(group.modelType, group.capabilities), + ProviderKeys: modelCatalogProviderKeys(providers), + Providers: providers, + SourceCount: len(sources), + Source: sourceText(sources), + Discount: discountText(sources), + RateLimits: aggregateRateLimits(sources), + Permission: permissionText(sources, accessRuleGroups), + Pricing: pricingText(sources), + Enabled: group.enabled, + StatusLabel: statusText(sources), + Sources: itemSources, + } +} + +func sortedCatalogSources(sources []catalogSource) []catalogSource { + out := append([]catalogSource(nil), sources...) + sort.SliceStable(out, func(i, j int) bool { + leftEnabled := out[i].model.Enabled && out[i].platform.Status == "enabled" + rightEnabled := out[j].model.Enabled && out[j].platform.Status == "enabled" + if leftEnabled != rightEnabled { + return leftEnabled + } + leftPriority := out[i].platform.Priority + rightPriority := out[j].platform.Priority + if leftPriority == 0 { + leftPriority = 9999 + } + if rightPriority == 0 { + rightPriority = 9999 + } + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + return out[i].model.ModelName < out[j].model.ModelName + }) + return out +} + +func modelCatalogProviders(sources []catalogSource) []ModelCatalogProviderSummary { + byKey := map[string]*ModelCatalogProviderSummary{} + for _, source := range sources { + key := source.providerKey + if key == "" { + key = "unknown" + } + current := byKey[key] + if current == nil { + current = &ModelCatalogProviderSummary{ + Key: key, + Name: source.providerName, + IconPath: source.provider.IconPath, + } + byKey[key] = current + } + current.SourceCount++ + } + items := make([]ModelCatalogProviderSummary, 0, len(byKey)) + for _, provider := range byKey { + items = append(items, *provider) + } + sort.Slice(items, func(i, j int) bool { + return items[i].Name < items[j].Name + }) + return items +} + +func modelCatalogProviderKeys(providers []ModelCatalogProviderSummary) []string { + keys := make([]string, 0, len(providers)) + for _, provider := range providers { + keys = append(keys, provider.Key) + } + return keys +} + +func sourceText(sources []catalogSource) ModelCatalogText { + label := formatInt(len(sources)) + " 个源" + return ModelCatalogText{ + Label: label, + Title: label, + } +} + +func sourceTitles(sources []catalogSource) []string { + titles := make([]string, 0, len(sources)) + for _, source := range sources { + titles = append(titles, firstNonEmpty(source.platform.Name, source.providerName, source.providerKey)+": "+source.model.ModelName) + } + return titles +} + +func discountText(sources []catalogSource) ModelCatalogText { + values := make([]float64, 0, len(sources)) + titles := make([]string, 0, len(sources)) + for _, source := range sources { + discount := effectiveDiscount(source.model, source.platform) + value := discountLabel(discount) + values = append(values, discount) + titles = append(titles, firstNonEmpty(source.platform.Name, source.model.ModelName)+": "+discountTitle(value, discount)) + } + return ModelCatalogText{ + Label: discountRangeLabel(values), + Title: strings.Join(titles, ";"), + } +} + +func effectiveDiscount(model store.PlatformModel, platform store.Platform) float64 { + if model.DiscountFactor > 0 { + return model.DiscountFactor + } + if model.PricingMode == "inherit_discount" && platform.DefaultDiscountFactor > 0 { + return platform.DefaultDiscountFactor + } + return 1 +} + +func discountLabel(discount float64) string { + if absFloat(discount-1) < 0.000001 { + return "无折扣" + } + return formatPercent(discount) +} + +func discountRangeLabel(values []float64) string { + discounts := uniqueDiscountValues(values) + if len(discounts) == 0 { + return "无折扣" + } + if len(discounts) == 1 { + return discountLabel(discounts[0]) + } + return discountLabel(discounts[0]) + " - " + discountLabel(discounts[len(discounts)-1]) +} + +func uniqueDiscountValues(values []float64) []float64 { + if len(values) == 0 { + return nil + } + out := append([]float64(nil), values...) + sort.Float64s(out) + unique := out[:0] + for _, value := range out { + if len(unique) == 0 || absFloat(value-unique[len(unique)-1]) >= 0.000001 { + unique = append(unique, value) + } + } + return unique +} + +func discountTitle(label string, discount float64) string { + if label == "无折扣" { + return "无折扣(100%)" + } + return formatPercent(discount) +} + +func effectiveModelRateLimits(model store.PlatformModel, platform store.Platform, runtimePolicyMap map[string]store.RuntimePolicySet) ModelCatalogRateLimits { + overridePolicy := objectValue(model.RuntimePolicyOverride["rateLimitPolicy"]) + runtimePolicy := map[string]any(nil) + if model.RuntimePolicySetID != "" { + runtimePolicy = runtimePolicyMap[model.RuntimePolicySetID].RateLimitPolicy + } + policies := []map[string]any{ + overridePolicy, + model.RateLimitPolicy, + runtimePolicy, + platform.RateLimitPolicy, + } + limits := ModelCatalogRateLimits{ + RPM: firstRateLimit(policies, "rpm"), + TPM: firstRateLimit(policies, "tpm_total"), + Concurrent: firstRateLimit(policies, "concurrent"), + } + limits.Label = rateLimitLabel(limits) + return limits +} + +func firstRateLimit(policies []map[string]any, metric string) *float64 { + for _, policy := range policies { + if value := readRateLimit(policy, metric); value != nil { + return value + } + } + return nil +} + +func readRateLimit(policy map[string]any, metric string) *float64 { + if len(policy) == 0 { + return nil + } + if rules, ok := policy["rules"].([]any); ok { + for _, item := range rules { + rule := objectValue(item) + if stringValue(rule["metric"]) != metric { + continue + } + if limit, ok := numberValue(rule["limit"]); ok { + return &limit + } + } + } + for _, key := range rateLimitKeys(metric) { + if value, ok := numberValue(policy[key]); ok { + return &value + } + } + platformLimits := objectValue(policy["platformLimits"]) + for _, key := range rateLimitKeys(metric) { + if value, ok := numberValue(platformLimits[key]); ok { + return &value + } + } + return nil +} + +func rateLimitKeys(metric string) []string { + switch metric { + case "rpm": + return []string{"rpm", "max_request_per_minute", "maxRequestsPerMinute"} + case "tpm_total": + return []string{"tpm_total", "tpm", "max_token_per_minute", "max_tokens_per_minute", "maxTokensPerMinute"} + case "concurrent": + return []string{"concurrent", "max_concurrent_requests", "maxConcurrentRequests"} + default: + return []string{metric} + } +} + +func aggregateRateLimits(sources []catalogSource) ModelCatalogRateLimits { + var rpm, tpm, concurrent float64 + var hasRPM, hasTPM, hasConcurrent bool + titles := make([]string, 0, len(sources)) + for _, source := range sources { + limits := source.rateLimits + if limits.RPM != nil { + rpm += *limits.RPM + hasRPM = true + } + if limits.TPM != nil { + tpm += *limits.TPM + hasTPM = true + } + if limits.Concurrent != nil { + concurrent += *limits.Concurrent + hasConcurrent = true + } + titles = append(titles, firstNonEmpty(source.platform.Name, source.model.ModelName)+": "+rateLimitLabel(limits)) + } + out := ModelCatalogRateLimits{Title: strings.Join(titles, ";")} + if hasRPM { + out.RPM = &rpm + } + if hasTPM { + out.TPM = &tpm + } + if hasConcurrent { + out.Concurrent = &concurrent + } + out.Label = rateLimitLabel(out) + return out +} + +func rateLimitLabel(limits ModelCatalogRateLimits) string { + return "RPM " + formatOptionalNumber(limits.RPM) + " / TPM " + formatOptionalNumber(limits.TPM) + " / 并发 " + formatOptionalNumber(limits.Concurrent) +} + +func permissionText(sources []catalogSource, rules map[string]accessRuleGroup) ModelCatalogPermission { + allow := map[string]bool{} + deny := map[string]bool{} + for _, source := range sources { + collectAccessRuleGroups(rules["platform_model:"+source.model.ID], allow, deny) + if source.model.PlatformID != "" { + collectAccessRuleGroups(rules["platform:"+source.model.PlatformID], allow, deny) + } + collectPermissionConfigGroups(source.model.PermissionConfig, allow) + } + allowNames := sortedMapKeys(allow) + denyNames := sortedMapKeys(deny) + if len(allowNames) == 0 && len(denyNames) == 0 { + return ModelCatalogPermission{Label: "不限制", Title: "没有用户组访问限制"} + } + labelParts := []string{} + if len(allowNames) > 0 { + label := "用户组 " + strings.Join(limitStrings(allowNames, 4), "、") + if len(allowNames) > 4 { + label += "等" + } + labelParts = append(labelParts, label) + } + if len(denyNames) > 0 { + label := "拒绝 " + strings.Join(limitStrings(denyNames, 3), "、") + if len(denyNames) > 3 { + label += "等" + } + labelParts = append(labelParts, label) + } + titleParts := []string{} + if len(allowNames) > 0 { + titleParts = append(titleParts, "允许用户组:"+strings.Join(allowNames, "、")) + } + if len(denyNames) > 0 { + titleParts = append(titleParts, "拒绝用户组:"+strings.Join(denyNames, "、")) + } + return ModelCatalogPermission{ + Label: strings.Join(labelParts, ";"), + Title: strings.Join(titleParts, ";"), + AllowGroups: allowNames, + DenyGroups: denyNames, + } +} + +type accessRuleGroup struct { + allow map[string]bool + deny map[string]bool +} + +func mapAccessRuleGroups(rules []store.AccessRule, userGroupMap map[string]string) map[string]accessRuleGroup { + groups := map[string]accessRuleGroup{} + for _, rule := range rules { + if rule.Status != "active" || rule.SubjectType != "user_group" || (rule.ResourceType != "platform" && rule.ResourceType != "platform_model") { + continue + } + key := rule.ResourceType + ":" + rule.ResourceID + current := groups[key] + if current.allow == nil { + current.allow = map[string]bool{} + current.deny = map[string]bool{} + } + groupName := firstNonEmpty(userGroupMap[rule.SubjectID], rule.SubjectID) + if rule.Effect == "allow" { + current.allow[groupName] = true + } + if rule.Effect == "deny" { + current.deny[groupName] = true + } + groups[key] = current + } + return groups +} + +func collectAccessRuleGroups(group accessRuleGroup, allow map[string]bool, deny map[string]bool) { + for name := range group.allow { + allow[name] = true + } + for name := range group.deny { + deny[name] = true + } +} + +func collectPermissionConfigGroups(config map[string]any, allow map[string]bool) { + if len(config) == 0 { + return + } + for _, key := range []string{"userGroupNames", "userGroups", "allowedUserGroups", "allowedUserGroupNames"} { + for _, value := range stringSliceValue(config[key]) { + allow[value] = true + } + } +} + +func pricingText(sources []catalogSource) ModelCatalogPricing { + lineSets := make([]string, 0, len(sources)) + linesBySource := make([][]string, 0, len(sources)) + for _, source := range sources { + lines := billingConfigLines(source.model.BillingConfig) + if len(lines) == 0 { + lines = []string{pricingModeLabel(source.model.PricingMode)} + } + linesBySource = append(linesBySource, lines) + lineSets = append(lineSets, strings.Join(lines, "\n")) + } + uniqueSets := uniqueStrings(lineSets) + lines := []string{} + if len(uniqueSets) <= 1 { + if len(linesBySource) > 0 { + lines = append(lines, linesBySource[0]...) + } + } else { + for index, source := range sources { + label := firstNonEmpty(source.platform.Name, source.model.ModelName) + for _, line := range linesBySource[index] { + lines = append(lines, label+": "+line) + } + } + } + if len(lines) == 0 { + lines = []string{"未配置具体价格"} + } + return ModelCatalogPricing{Lines: lines, Title: strings.Join(lines, ";")} +} + +func billingConfigLines(config map[string]any) []string { + if len(config) == 0 { + return nil + } + if textPricingResource(config) { + if lines := textPricingLines(config); len(lines) > 0 { + return lines + } + } + if basePrice, ok := numberValue(config["basePrice"]); ok { + resourceType := firstNonEmpty(stringValue(config["resourceType"]), "model") + return []string{priceLine(resourceType, map[string]any{"basePrice": basePrice, "dynamicWeight": config["dynamicWeight"], "unit": config["unit"]})} + } + lines := textPricingLines(config) + for _, resource := range []string{"image", "image_edit", "video", "audio", "music", "digital_human", "model"} { + if nested := objectValue(config[resource]); len(nested) > 0 { + if _, ok := numberValue(nested["basePrice"]); ok { + lines = append(lines, priceLine(resource, nested)) + continue + } + } + for _, baseKey := range resourceBaseKeys(resource) { + if value, ok := numberValue(config[baseKey]); ok { + lines = append(lines, resourceBaseLine(resource, value)) + break + } + } + } + return uniqueStrings(lines) +} + +func textPricingResource(config map[string]any) bool { + switch strings.ToLower(stringValue(config["resourceType"])) { + case "text", "text_total", "text_input", "text_output": + return true + default: + return false + } +} + +func textPricingLines(config map[string]any) []string { + if len(config) == 0 { + return nil + } + inputPrice, hasInput := textPriceFromConfig(config, []string{"textInputPer1k", "textInput", "text_input", "inputTokenPrice", "inputPrice"}) + outputPrice, hasOutput := textPriceFromConfig(config, []string{"textOutputPer1k", "textOutput", "text_output", "outputTokenPrice", "outputPrice"}) + + for _, key := range []string{"formulaConfig", "formula_config"} { + formulaConfig := objectValue(config[key]) + if !hasInput { + inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) + } + if !hasOutput { + outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) + } + } + + for _, resource := range []string{"text", "text_total"} { + nested := objectValue(config[resource]) + if len(nested) == 0 { + continue + } + if !hasInput { + inputPrice, hasInput = textPriceFromConfig(nested, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) + } + if !hasOutput { + outputPrice, hasOutput = textPriceFromConfig(nested, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) + } + for _, key := range []string{"formulaConfig", "formula_config"} { + formulaConfig := objectValue(nested[key]) + if !hasInput { + inputPrice, hasInput = textPriceFromConfig(formulaConfig, []string{"inputTokenPrice", "textInputPer1k", "textInput", "text_input", "inputPrice"}) + } + if !hasOutput { + outputPrice, hasOutput = textPriceFromConfig(formulaConfig, []string{"outputTokenPrice", "textOutputPer1k", "textOutput", "text_output", "outputPrice"}) + } + } + if resource == "text" && !hasOutput { + outputPrice, hasOutput = textPriceFromConfig(nested, []string{"basePrice"}) + } + if !hasInput { + inputPrice, hasInput = textPriceFromConfig(nested, []string{"basePrice"}) + } + } + + if basePrice, ok := numberValue(config["basePrice"]); ok { + switch strings.ToLower(stringValue(config["resourceType"])) { + case "text": + if !hasInput { + inputPrice, hasInput = basePrice, true + } + if !hasOutput { + outputPrice, hasOutput = basePrice, true + } + case "text_total", "text_input": + if !hasInput { + inputPrice, hasInput = basePrice, true + } + case "text_output": + if !hasOutput { + outputPrice, hasOutput = basePrice, true + } + } + } + + lines := []string{} + if hasInput { + lines = append(lines, "输入 "+formatNumber(inputPrice)+"/k tokens") + } + if hasOutput { + lines = append(lines, "输出 "+formatNumber(outputPrice)+"/k tokens") + } + return uniqueStrings(lines) +} + +func textPriceFromConfig(config map[string]any, keys []string) (float64, bool) { + if len(config) == 0 { + return 0, false + } + for _, key := range keys { + if value, ok := numberValue(config[key]); ok { + return value, true + } + } + return 0, false +} + +func resourceBaseKeys(resource string) []string { + switch resource { + case "image": + return []string{"imageBase"} + case "image_edit": + return []string{"editBase", "imageEditBase"} + case "video": + return []string{"videoBase"} + case "audio": + return []string{"audioBase"} + case "music": + return []string{"musicBase"} + case "digital_human": + return []string{"digitalHumanBase", "digital_humanBase"} + case "model": + return []string{"modelBase"} + case "text": + return []string{"textBase"} + default: + return []string{resource + "Base"} + } +} + +func priceLine(resourceType string, config map[string]any) string { + basePrice, _ := numberValue(config["basePrice"]) + weights := resolutionWeightEntries(config["dynamicWeight"]) + if len(weights) > 0 { + details := make([]string, 0, len(weights)) + for _, item := range weights { + details = append(details, item.key+" "+formatNumber(basePrice*item.weight)) + } + line := resourceTypeLabel(resourceType) + ":" + strings.Join(details, " / ") + if resourceType == "video" { + line += "(5秒基准)" + } + return line + } + unit := firstNonEmpty(stringValue(config["unit"]), resourceType) + if resourceType == "video" && (unit == "video" || unit == "5s") { + return resourceTypeLabel(resourceType) + ":" + formatNumber(basePrice) + " / 5秒基准" + } + return resourceTypeLabel(resourceType) + ":" + formatNumber(basePrice) + " / " + unitLabel(unit) +} + +func resourceBaseLine(resourceType string, basePrice float64) string { + if resourceType == "video" { + return resourceTypeLabel(resourceType) + ":" + formatNumber(basePrice) + " / 5秒基准" + } + return resourceTypeLabel(resourceType) + ":" + formatNumber(basePrice) +} + +type resolutionWeight struct { + key string + weight float64 +} + +func resolutionWeightEntries(value any) []resolutionWeight { + record := objectValue(value) + entries := make([]resolutionWeight, 0, len(record)) + for key, raw := range record { + if !isResolutionKey(key) { + continue + } + weight, ok := numberValue(raw) + if !ok { + continue + } + entries = append(entries, resolutionWeight{key: key, weight: weight}) + } + sort.Slice(entries, func(i, j int) bool { + return resolutionSortValue(entries[i].key) < resolutionSortValue(entries[j].key) + }) + return entries +} + +func isResolutionKey(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + if strings.HasSuffix(normalized, "k") { + _, ok := numberValue(strings.TrimSuffix(normalized, "k")) + return ok + } + if strings.HasSuffix(normalized, "p") { + _, ok := numberValue(strings.TrimSuffix(normalized, "p")) + return ok + } + return false +} + +func resolutionSortValue(value string) float64 { + normalized := strings.ToLower(strings.TrimSpace(value)) + if strings.HasSuffix(normalized, "k") { + number, _ := numberValue(strings.TrimSuffix(normalized, "k")) + return number * 1000 + } + if strings.HasSuffix(normalized, "p") { + number, _ := numberValue(strings.TrimSuffix(normalized, "p")) + return number + } + return 0 +} + +func statusText(sources []catalogSource) string { + statuses := uniqueStringsFromSources(sources, func(source catalogSource) string { + if source.platform.Status != "" { + return source.platform.Status + } + if source.model.Enabled { + return "enabled" + } + return "disabled" + }) + priorities := uniqueStringsFromSources(sources, func(source catalogSource) string { + if source.platform.Priority == 0 { + return "" + } + return "优先级 " + formatInt(source.platform.Priority) + }) + parts := append(statuses, priorities...) + if len(parts) == 0 { + return "平台模型" + } + return strings.Join(parts, " · ") +} + +func modelCatalogCapabilityFilters(items []ModelCatalogItem) []ModelCatalogFilterOption { + counts := map[string]int{} + for _, item := range items { + for _, value := range capabilityFilterValuesForItem(item) { + counts[value]++ + } + } + options := []ModelCatalogFilterOption{{Value: "all", Label: "全部", Count: len(items)}} + for _, option := range []ModelCatalogFilterOption{ + {Value: "chat", Label: "对话"}, + {Value: "image", Label: "图像"}, + {Value: "video", Label: "视频"}, + {Value: "audio", Label: "音频"}, + {Value: "embedding", Label: "Embedding"}, + {Value: "tools", Label: "工具调用"}, + {Value: "omni", Label: "全模态"}, + } { + if count := counts[option.Value]; count > 0 { + option.Count = count + options = append(options, option) + } + } + return options +} + +func capabilityFilterValuesForItem(item ModelCatalogItem) []string { + values := capabilityFilterValues(item.ModelType, nil) + for _, tag := range item.CapabilityTags { + switch tag { + case "工具调用": + values = append(values, "tools") + case "全模态", "多模态": + values = append(values, "omni") + } + } + return uniqueStrings(values) +} + +func modelCatalogProviderFilters(items []ModelCatalogItem) []ModelCatalogFilterOption { + counts := map[string]int{} + labels := map[string]string{} + icons := map[string]string{} + for _, item := range items { + for _, provider := range item.Providers { + counts[provider.Key]++ + labels[provider.Key] = provider.Name + if provider.IconPath != "" { + icons[provider.Key] = provider.IconPath + } + } + } + options := []ModelCatalogFilterOption{{Value: "all", Label: "全部", Count: len(items)}} + keys := sortedIntMapKeys(counts) + sort.Slice(keys, func(i, j int) bool { + return labels[keys[i]] < labels[keys[j]] + }) + for _, key := range keys { + options = append(options, ModelCatalogFilterOption{ + Value: key, + Label: firstNonEmpty(labels[key], key), + Count: counts[key], + IconPath: icons[key], + }) + } + return options +} + +func capabilityTagsForGroup(modelTypes []string, capabilities map[string]any) []string { + tags := make([]string, 0, len(modelTypes)+4) + for _, modelType := range modelTypes { + tags = append(tags, capabilityLabel(modelType)) + } + if boolAnyValue(capabilities["multimodal"]) || boolAnyValue(capabilities["vision"]) { + tags = append(tags, "多模态") + } + if boolAnyValue(capabilities["reasoning"]) { + tags = append(tags, "推理") + } + if boolAnyValue(capabilities["structured_output"]) || boolAnyValue(capabilities["structuredOutput"]) { + tags = append(tags, "结构化输出") + } + if boolAnyValue(capabilities["tools_call"]) || boolAnyValue(capabilities["toolCall"]) || boolAnyValue(capabilities["function_call"]) { + tags = append(tags, "工具调用") + } + return uniqueStrings(tags) +} + +func capabilityFilterValues(modelTypes []string, capabilities map[string]any) []string { + values := []string{} + for _, modelType := range modelTypes { + normalized := strings.ToLower(strings.TrimSpace(modelType)) + switch { + case normalized == "text_embedding" || normalized == "embedding": + values = append(values, "embedding") + case normalized == "tools_call": + values = append(values, "tools") + case normalized == "omni": + values = append(values, "omni") + case strings.Contains(normalized, "video"): + values = append(values, "video") + case strings.Contains(normalized, "audio") || strings.Contains(normalized, "speech"): + values = append(values, "audio") + case strings.Contains(normalized, "image"): + values = append(values, "image") + case normalized == "text_generate" || normalized == "chat" || normalized == "responses" || strings.Contains(normalized, "text"): + values = append(values, "chat") + } + } + if boolAnyValue(capabilities["tools_call"]) || boolAnyValue(capabilities["toolCall"]) || boolAnyValue(capabilities["function_call"]) { + values = append(values, "tools") + } + if boolAnyValue(capabilities["multimodal"]) { + values = append(values, "omni") + } + return uniqueStrings(values) +} + +func capabilityLabel(value string) string { + labels := map[string]string{ + "text_generate": "文本生成", + "chat": "对话", + "responses": "Responses", + "text_embedding": "Embedding", + "embedding": "Embedding", + "image_generate": "图像生成", + "image_edit": "图像编辑", + "image_analysis": "图像分析", + "video_generate": "视频生成", + "image_to_video": "图生视频", + "text_to_video": "文生视频", + "video_edit": "视频编辑", + "video_understanding": "视频理解", + "audio_generate": "音频生成", + "text_to_speech": "语音合成", + "audio_understanding": "音频理解", + "tools_call": "工具调用", + "omni": "全模态", + "omni_video": "全模态视频", + "structured_output": "结构化输出", + "digital_human": "数字人", + "model_3d": "3D 模型", + } + if label, ok := labels[value]; ok { + return label + } + return value +} + +func modelDescription(model store.PlatformModel, baseModel store.BaseModel) string { + rawModel := objectValue(baseModel.Metadata["rawModel"]) + capabilityRawModel := objectValue(model.Capabilities["rawModel"]) + return firstNonEmpty( + stringValue(baseModel.Metadata["description"]), + stringValue(baseModel.Metadata["modelDescription"]), + stringValue(rawModel["description"]), + stringValue(model.Capabilities["description"]), + stringValue(model.Capabilities["modelDescription"]), + stringValue(capabilityRawModel["description"]), + ) +} + +func modelIconPath(model store.PlatformModel, baseModel store.BaseModel, provider store.CatalogProvider) string { + rawModel := objectValue(baseModel.Metadata["rawModel"]) + return firstNonEmpty( + stringValue(baseModel.Metadata["iconPath"]), + stringValue(rawModel["icon_path"]), + stringValue(rawModel["iconPath"]), + stringValue(model.Capabilities["iconPath"]), + stringValue(model.Capabilities["icon_path"]), + stringValue(model.BillingConfig["iconPath"]), + stringValue(model.BillingConfig["icon_path"]), + provider.IconPath, + ) +} + +func catalogAliasKey(model store.PlatformModel) string { + key := strings.ToLower(strings.TrimSpace(firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName))) + if key == "" { + return model.ID + } + return key +} + +func modelProviderKey(model store.PlatformModel, baseModel store.BaseModel, platform store.Platform) string { + return normalizeCatalogProviderKey(firstNonEmpty( + baseModel.ProviderKey, + stringValue(baseModel.Metadata["sourceProviderCode"]), + stringValue(baseModel.Metadata["providerKey"]), + model.Provider, + platform.Provider, + platform.Name, + model.PlatformName, + )) +} + +func normalizeCatalogProviderKey(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "unknown" + } + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.Join(strings.Fields(normalized), "-") + switch normalized { + case "google-gemini": + return "gemini" + case "open-ai": + return "openai" + } + return normalized +} + +func mapPlatforms(platforms []store.Platform) map[string]store.Platform { + out := map[string]store.Platform{} + for _, platform := range platforms { + out[platform.ID] = platform + } + return out +} + +func mapProviders(providers []store.CatalogProvider) map[string]store.CatalogProvider { + out := map[string]store.CatalogProvider{} + for _, provider := range providers { + keys := []string{ + provider.ProviderKey, + provider.Code, + provider.DisplayName, + stringValue(provider.Metadata["sourceCode"]), + } + for _, key := range keys { + normalized := normalizeCatalogProviderKey(key) + if normalized != "unknown" { + out[normalized] = provider + } + } + } + return out +} + +func mapRuntimePolicies(policies []store.RuntimePolicySet) map[string]store.RuntimePolicySet { + out := map[string]store.RuntimePolicySet{} + for _, policy := range policies { + out[policy.ID] = policy + } + return out +} + +func mapUserGroups(groups []store.UserGroup) map[string]string { + out := map[string]string{} + for _, group := range groups { + out[group.ID] = firstNonEmpty(group.Name, group.GroupKey, group.ID) + } + return out +} + +func mapBaseModels(models []store.BaseModel) map[string]store.BaseModel { + out := map[string]store.BaseModel{} + for _, model := range models { + out[model.ID] = model + } + return out +} + +func mergeCatalogObjects(left map[string]any, right map[string]any) map[string]any { + if len(left) == 0 && len(right) == 0 { + return nil + } + out := cloneObject(left) + for key, value := range right { + out[key] = value + } + return out +} + +func cloneObject(value map[string]any) map[string]any { + if len(value) == 0 { + return nil + } + out := make(map[string]any, len(value)) + for key, item := range value { + out[key] = item + } + return out +} + +func cloneStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + return append([]string(nil), values...) +} + +func uniqueStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" || seen[trimmed] { + continue + } + seen[trimmed] = true + out = append(out, trimmed) + } + return out +} + +func uniqueStringsFromSources(sources []catalogSource, selector func(catalogSource) string) []string { + values := make([]string, 0, len(sources)) + for _, source := range sources { + values = append(values, selector(source)) + } + return uniqueStrings(values) +} + +func limitStrings(values []string, limit int) []string { + if len(values) <= limit { + return values + } + return values[:limit] +} + +func sortedMapKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func objectValue(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + default: + return nil + } +} + +func stringValue(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return "" +} + +func stringSliceValue(value any) []string { + switch typed := value.(type) { + case []string: + return uniqueStrings(typed) + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text := stringValue(item); text != "" { + out = append(out, text) + } + } + return uniqueStrings(out) + default: + return nil + } +} + +func sortedIntMapKeys(values map[string]int) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func boolAnyValue(value any) bool { + typed, ok := value.(bool) + return ok && typed +} + +func numberValue(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case int32: + return float64(typed), true + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, false + } + if number, err := strconv.ParseFloat(trimmed, 64); err == nil { + return number, true + } + return 0, false + default: + return 0, false + } +} + +func formatOptionalNumber(value *float64) string { + if value == nil { + return "-" + } + return formatLimitNumber(*value) +} + +func formatLimitNumber(value float64) string { + switch { + case absFloat(value) >= 10000: + return formatNumber(value/10000) + "万" + case absFloat(value) >= 1000: + return formatNumber(value/1000) + "k" + default: + return formatNumber(value) + } +} + +func formatPercent(value float64) string { + return formatNumber(value*100) + "%" +} + +func formatNumber(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func formatInt(value int) string { + return strconv.Itoa(value) +} + +func absFloat(value float64) float64 { + if value < 0 { + return -value + } + return value +} + +func pricingModeLabel(mode string) string { + switch mode { + case "inherit": + return "跟随基准定价" + case "inherit_discount": + return "继承并折扣" + case "custom": + return "自定义定价" + default: + if strings.TrimSpace(mode) != "" { + return mode + } + return "未配置定价" + } +} + +func resourceTypeLabel(value string) string { + labels := map[string]string{ + "text": "文本", + "text_total": "文本", + "text_input": "文本输入", + "text_output": "文本输出", + "image": "图像", + "image_edit": "图像编辑", + "video": "视频", + "audio": "音频", + "music": "音乐", + "digital_human": "数字人", + "model": "模型", + } + if label, ok := labels[value]; ok { + return label + } + return value +} + +func unitLabel(value string) string { + labels := map[string]string{ + "image": "张", + "5s": "5秒", + "second": "秒", + "item": "次", + "unit": "单位", + "1k_tokens": "k tokens", + "character_1k": "千字符", + } + if label, ok := labels[value]; ok { + return label + } + return value +} diff --git a/apps/api/internal/httpapi/model_catalog_test.go b/apps/api/internal/httpapi/model_catalog_test.go new file mode 100644 index 0000000..fa191be --- /dev/null +++ b/apps/api/internal/httpapi/model_catalog_test.go @@ -0,0 +1,221 @@ +package httpapi + +import ( + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestBuildModelCatalogAggregatesSources(t *testing.T) { + models := []store.PlatformModel{ + { + ID: "model-a", + PlatformID: "platform-a", + ModelName: "seedance", + ModelAlias: "Seedance-2.0", + ModelType: store.StringList{"image_generate"}, + DisplayName: "Seedance Source A", + BillingConfig: map[string]any{ + "image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}}, + }, + RateLimitPolicy: map[string]any{ + "platformLimits": map[string]any{ + "max_request_per_minute": 60, + "max_token_per_minute": 1000, + "max_concurrent_requests": 2, + }, + }, + PricingMode: "inherit_discount", + Enabled: true, + }, + { + ID: "model-b", + PlatformID: "platform-b", + ModelName: "seedance", + ModelAlias: "Seedance-2.0", + ModelType: store.StringList{"image_generate"}, + DisplayName: "Seedance Source B", + BillingConfig: map[string]any{ + "image": map[string]any{"basePrice": float64(10), "dynamicWeight": map[string]any{"1K": float64(1), "2K": float64(2)}}, + }, + RateLimitPolicy: map[string]any{ + "rpm": 40, + "tpm": 2000, + "concurrent": 3, + }, + DiscountFactor: 0.8, + PricingMode: "custom", + Enabled: true, + }, + } + platforms := []store.Platform{ + {ID: "platform-a", Provider: "volces", Name: "火山引擎", Status: "enabled", Priority: 20, DefaultDiscountFactor: 1}, + {ID: "platform-b", Provider: "gemini", Name: "Gemini", Status: "enabled", Priority: 10, DefaultDiscountFactor: 1}, + } + providers := []store.CatalogProvider{ + {ProviderKey: "volces", DisplayName: "火山引擎", IconPath: "volces.png"}, + {ProviderKey: "gemini", DisplayName: "Google Gemini", IconPath: "gemini.png"}, + } + accessRules := []store.AccessRule{ + {SubjectType: "user_group", SubjectID: "group-vip", ResourceType: "platform", ResourceID: "platform-b", Effect: "allow", Status: "active"}, + {SubjectType: "user_group", SubjectID: "group-blocked", ResourceType: "platform", ResourceID: "platform-a", Effect: "deny", Status: "active"}, + } + userGroups := []store.UserGroup{ + {ID: "group-vip", GroupKey: "vip", Name: "VIP 用户组"}, + {ID: "group-blocked", GroupKey: "blocked", Name: "Blocked 用户组"}, + } + baseModels := []store.BaseModel{ + {ID: "", Metadata: map[string]any{"description": "高质量图像生成模型"}}, + } + + response := buildModelCatalog(models, platforms, providers, nil, accessRules, userGroups, baseModels) + if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 { + t.Fatalf("unexpected summary: %+v", response.Summary) + } + item := response.Items[0] + if item.SourceCount != 2 { + t.Fatalf("expected merged source count, got %d", item.SourceCount) + } + if item.Source.Label != "2 个源" { + t.Fatalf("expected source label to only show count, got %q", item.Source.Label) + } + if item.RateLimits.RPM == nil || *item.RateLimits.RPM != 100 { + t.Fatalf("expected summed rpm 100, got %+v", item.RateLimits.RPM) + } + if item.RateLimits.TPM == nil || *item.RateLimits.TPM != 3000 { + t.Fatalf("expected summed tpm 3000, got %+v", item.RateLimits.TPM) + } + if item.RateLimits.Concurrent == nil || *item.RateLimits.Concurrent != 5 { + t.Fatalf("expected summed concurrency 5, got %+v", item.RateLimits.Concurrent) + } + if item.Permission.Label != "用户组 VIP 用户组;拒绝 Blocked 用户组" { + t.Fatalf("expected permission label from access rules, got %q", item.Permission.Label) + } + if len(item.Permission.AllowGroups) != 1 || item.Permission.AllowGroups[0] != "VIP 用户组" { + t.Fatalf("expected allow permission groups, got %+v", item.Permission.AllowGroups) + } + if len(item.Permission.DenyGroups) != 1 || item.Permission.DenyGroups[0] != "Blocked 用户组" { + t.Fatalf("expected deny permission groups, got %+v", item.Permission.DenyGroups) + } + if item.Discount.Label != "80% - 无折扣" { + t.Fatalf("expected friendly discount label, got %q", item.Discount.Label) + } + if len(item.ProviderKeys) != 2 { + t.Fatalf("expected both providers on merged item, got %+v", item.ProviderKeys) + } + if !hasFilterCount(response.Filters.Providers, "volces", 1) || !hasFilterCount(response.Filters.Providers, "gemini", 1) { + t.Fatalf("expected provider filters to count merged model for each provider: %+v", response.Filters.Providers) + } + if !hasFilterCount(response.Filters.Capabilities, "image", 1) { + t.Fatalf("expected image capability filter: %+v", response.Filters.Capabilities) + } + if got := item.Pricing.Lines[0]; got != "图像:1K 10 / 2K 20" { + t.Fatalf("unexpected pricing line %q", got) + } +} + +func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) { + models := []store.PlatformModel{ + { + ID: "glm-volces", + PlatformID: "platform-volces", + BaseModelID: "base-glm", + ModelName: "glm-4.7", + ModelAlias: "GLM-4.7", + ModelType: store.StringList{"text_generate"}, + DisplayName: "GLM-4.7", + Enabled: true, + }, + { + ID: "glm-zhipu", + PlatformID: "platform-zhipu", + BaseModelID: "base-glm", + ModelName: "glm-4.7", + ModelAlias: "GLM-4.7", + ModelType: store.StringList{"text_generate"}, + DisplayName: "GLM-4.7", + Enabled: true, + }, + } + platforms := []store.Platform{ + {ID: "platform-volces", Provider: "volces-openai", Name: "火山引擎(OpenAI兼容)", Status: "enabled"}, + {ID: "platform-zhipu", Provider: "zhipu-openai", Name: "智谱官方", Status: "enabled"}, + } + providers := []store.CatalogProvider{ + {ProviderKey: "volces-openai", DisplayName: "火山引擎(OpenAI兼容)", IconPath: "volces.png"}, + {ProviderKey: "zhipu-openai", DisplayName: "智谱AI", IconPath: "zhipu.png"}, + } + baseModels := []store.BaseModel{ + {ID: "base-glm", ProviderKey: "zhipu-openai", ProviderModelName: "glm-4.7", ModelAlias: "GLM-4.7"}, + } + + response := buildModelCatalog(models, platforms, providers, nil, nil, nil, baseModels) + if response.Summary.ModelCount != 1 || response.Summary.SourceCount != 2 { + t.Fatalf("unexpected summary: %+v", response.Summary) + } + item := response.Items[0] + if len(item.ProviderKeys) != 1 || item.ProviderKeys[0] != "zhipu-openai" { + t.Fatalf("expected model provider zhipu-openai only, got %+v", item.ProviderKeys) + } + if len(item.Providers) != 1 || item.Providers[0].Name != "智谱AI" || item.Providers[0].SourceCount != 2 { + t.Fatalf("expected provider summary to aggregate both sources under model provider, got %+v", item.Providers) + } + if !hasFilterCount(response.Filters.Providers, "zhipu-openai", 1) { + t.Fatalf("expected zhipu provider filter count 1, got %+v", response.Filters.Providers) + } + if hasFilterCount(response.Filters.Providers, "volces-openai", 1) { + t.Fatalf("did not expect platform provider in model provider filters: %+v", response.Filters.Providers) + } +} + +func TestBillingConfigLinesShowsTextInputAndOutputPricing(t *testing.T) { + lines := billingConfigLines(map[string]any{ + "text_total": map[string]any{ + "basePrice": 0.01, + "formulaConfig": map[string]any{ + "inputTokenPrice": 0.01, + "outputTokenPrice": 0.03, + }, + }, + }) + + if len(lines) != 2 { + t.Fatalf("expected input and output pricing lines, got %+v", lines) + } + if lines[0] != "输入 0.01/k tokens" { + t.Fatalf("unexpected input pricing line %q", lines[0]) + } + if lines[1] != "输出 0.03/k tokens" { + t.Fatalf("unexpected output pricing line %q", lines[1]) + } +} + +func TestBillingConfigLinesShowsVideoFiveSecondBasis(t *testing.T) { + lines := billingConfigLines(map[string]any{ + "video": map[string]any{ + "basePrice": float64(75), + "dynamicWeight": map[string]any{"480p": float64(1), "720p": float64(2)}, + }, + }) + + if len(lines) != 1 { + t.Fatalf("expected one video pricing line, got %+v", lines) + } + if lines[0] != "视频:480p 75 / 720p 150(5秒基准)" { + t.Fatalf("unexpected video pricing line %q", lines[0]) + } + + flatLines := billingConfigLines(map[string]any{"videoBase": float64(100)}) + if len(flatLines) != 1 || flatLines[0] != "视频:100 / 5秒基准" { + t.Fatalf("unexpected flat video pricing line %+v", flatLines) + } +} + +func hasFilterCount(options []ModelCatalogFilterOption, value string, count int) bool { + for _, option := range options { + if option.Value == value && option.Count == count { + return true + } + } + return false +} diff --git a/apps/api/internal/httpapi/model_response.go b/apps/api/internal/httpapi/model_response.go new file mode 100644 index 0000000..4e53d7e --- /dev/null +++ b/apps/api/internal/httpapi/model_response.go @@ -0,0 +1,48 @@ +package httpapi + +import ( + "context" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func (s *Server) platformModelResponse(ctx context.Context, model store.PlatformModel) store.PlatformModel { + model = s.withEffectiveResponseBillingConfig(ctx, model) + return store.FilterPlatformModelBillingConfig(model) +} + +func (s *Server) platformModelResponses(ctx context.Context, models []store.PlatformModel) []store.PlatformModel { + items := make([]store.PlatformModel, len(models)) + for i, model := range models { + items[i] = s.platformModelResponse(ctx, model) + } + return items +} + +func (s *Server) withEffectiveResponseBillingConfig(ctx context.Context, model store.PlatformModel) store.PlatformModel { + config := model.BillingConfig + if model.PricingRuleSetID != "" { + if ruleSetConfig, err := s.store.PricingRuleSetBillingConfig(ctx, model.PricingRuleSetID); err == nil && len(ruleSetConfig) > 0 { + config = ruleSetConfig + } + } + if len(model.BillingConfigOverride) > 0 { + config = mergeResponseBillingConfig(config, model.BillingConfigOverride) + } + model.BillingConfig = config + return model +} + +func mergeResponseBillingConfig(base map[string]any, override map[string]any) map[string]any { + if len(base) == 0 && len(override) == 0 { + return nil + } + out := make(map[string]any, len(base)+len(override)) + for key, value := range base { + out[key] = value + } + for key, value := range override { + out[key] = value + } + return out +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 47abf86..aab384d 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -92,6 +92,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.Handle("POST /api/admin/platform-models", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) mux.Handle("DELETE /api/admin/platform-models/{modelID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel))) mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels))) + mux.Handle("GET /api/v1/model-catalog", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog))) mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms))) mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels))) mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels))) diff --git a/apps/api/internal/store/candidates.go b/apps/api/internal/store/candidates.go index 6f77e8d..2c04b3f 100644 --- a/apps/api/internal/store/candidates.go +++ b/apps/api/internal/store/candidates.go @@ -17,7 +17,7 @@ SELECT p.id::text, p.platform_key, p.name, p.provider, p.retry_policy, p.rate_limit_policy, COALESCE(p.dynamic_priority, p.priority) AS effective_priority, m.id::text, COALESCE(m.base_model_id::text, ''), COALESCE(b.canonical_model_key, ''), - COALESCE(b.provider_model_name, ''), m.model_name, COALESCE(m.model_alias, ''), + COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), m.model_name, COALESCE(m.model_alias, ''), $2 AS requested_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, ''), @@ -32,17 +32,22 @@ LEFT JOIN model_catalog_providers cp ON cp.provider_key = p.provider OR cp.provi LEFT JOIN base_model_catalog b ON b.id = m.base_model_id LEFT JOIN model_runtime_policy_sets rp ON rp.id = COALESCE(m.runtime_policy_set_id, b.runtime_policy_set_id) LEFT JOIN runtime_client_states s - ON s.client_id = p.platform_key || ':' || $2 || ':' || m.model_name + ON s.client_id = p.platform_key || ':' || $2 || ':' || COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) WHERE p.status = 'enabled' AND p.deleted_at IS NULL AND m.enabled = true AND m.model_type @> jsonb_build_array($2) AND (p.cooldown_until IS NULL OR p.cooldown_until <= now()) AND ( - m.model_name = $1 - OR m.model_alias = $1 - OR b.canonical_model_key = $1 - OR b.provider_model_name = $1 + (COALESCE(m.model_alias, '') <> '' AND m.model_alias = $1) + OR ( + COALESCE(m.model_alias, '') = '' + AND ( + m.model_name = $1 + OR b.canonical_model_key = $1 + OR b.provider_model_name = $1 + ) + ) ) ORDER BY effective_priority ASC, COALESCE(s.limiter_ratio, 0) ASC, @@ -137,7 +142,8 @@ ORDER BY effective_priority ASC, item.RuntimeRateLimitPolicy = decodeObject(runtimeRateLimitPolicy) item.AutoDisablePolicy = decodeObject(autoDisablePolicy) item.DegradePolicy = decodeObject(degradePolicy) - item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, item.ModelName) + upstreamModelName := firstNonEmpty(item.ProviderModelName, item.ModelName) + item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, upstreamModelName) item.QueueKey = item.ClientID items = append(items, item) } diff --git a/apps/api/internal/store/model_billing_filter.go b/apps/api/internal/store/model_billing_filter.go new file mode 100644 index 0000000..6e39b94 --- /dev/null +++ b/apps/api/internal/store/model_billing_filter.go @@ -0,0 +1,147 @@ +package store + +import "strings" + +func FilterPlatformModelBillingConfigs(models []PlatformModel) []PlatformModel { + filtered := make([]PlatformModel, len(models)) + for i, model := range models { + filtered[i] = FilterPlatformModelBillingConfig(model) + } + return filtered +} + +func FilterPlatformModelBillingConfig(model PlatformModel) PlatformModel { + model.BillingConfig = filterBillingConfigByModelTypes(model.BillingConfig, model.ModelType) + model.BillingConfigOverride = filterBillingConfigByModelTypes(model.BillingConfigOverride, model.ModelType) + return model +} + +func filterBillingConfigByModelTypes(config map[string]any, modelTypes []string) map[string]any { + if len(config) == 0 { + return nil + } + resources := billingResourcesForModelTypes(modelTypes) + if len(resources) == 0 { + return cloneBillingConfig(config) + } + + filtered := map[string]any{} + for key, value := range config { + if billingConfigKeyAllowed(key, resources) { + filtered[key] = value + } + } + + if resources["image_edit"] && !hasAnyKey(filtered, "image_edit", "editBase") && !hasAnyKey(config, "image_edit", "editBase") { + copyBillingConfigKey(filtered, config, "image") + copyBillingConfigKey(filtered, config, "imageBase") + } + + if len(filtered) == 0 { + return nil + } + return filtered +} + +func billingResourcesForModelTypes(modelTypes []string) map[string]bool { + resources := map[string]bool{} + for _, modelType := range modelTypes { + switch normalizeBillingType(modelType) { + case "chat", "text", "responses", "text_generate", "text_embedding", "embedding", + "image_analysis", "video_understanding", "audio_understanding", "omni", "tools_call": + resources["text"] = true + case "image", "images.generations", "image_generate": + resources["image"] = true + case "images.edits", "image_edit": + resources["image_edit"] = true + case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video", + "video_edit", "omni_video", "video_reference", "video_first_last_frame": + resources["video"] = true + case "audio", "audio_generate", "text_to_speech", "speech": + resources["audio"] = true + case "music", "music_generate": + resources["music"] = true + case "digital_human", "digital_human_generate": + resources["digital_human"] = true + case "model", "model_3d", "text_to_model", "image_to_model", "multiview_to_model", "mesh_edit": + resources["model"] = true + default: + inferBillingResources(modelType, resources) + } + } + return resources +} + +func inferBillingResources(modelType string, resources map[string]bool) { + normalized := normalizeBillingType(modelType) + switch { + case strings.Contains(normalized, "digital_human"): + resources["digital_human"] = true + case strings.Contains(normalized, "video"): + resources["video"] = true + case strings.Contains(normalized, "image"): + resources["image"] = true + case strings.Contains(normalized, "audio") || strings.Contains(normalized, "speech"): + resources["audio"] = true + case strings.Contains(normalized, "music"): + resources["music"] = true + case strings.Contains(normalized, "model") || strings.Contains(normalized, "mesh"): + resources["model"] = true + case strings.Contains(normalized, "text") || strings.Contains(normalized, "token"): + resources["text"] = true + } +} + +func billingConfigKeyAllowed(key string, resources map[string]bool) bool { + switch normalizeBillingConfigKey(key) { + case "text", "texttotal", "text_total", "textinputper1k", "textoutputper1k", "textinput", "textoutput", "text_input", "text_output", "inputtokenprice", "outputtokenprice": + return resources["text"] + case "image", "imagebase": + return resources["image"] + case "image_edit", "imageedit", "editbase": + return resources["image_edit"] + case "video", "videobase": + return resources["video"] + case "audio", "audiobase": + return resources["audio"] + case "music", "musicbase": + return resources["music"] + case "digital_human", "digitalhuman", "digitalhumanbase": + return resources["digital_human"] + case "model", "modelbase": + return resources["model"] + default: + return false + } +} + +func normalizeBillingType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func normalizeBillingConfigKey(value string) string { + return strings.ReplaceAll(strings.ReplaceAll(normalizeBillingType(value), "-", "_"), " ", "") +} + +func cloneBillingConfig(config map[string]any) map[string]any { + clone := make(map[string]any, len(config)) + for key, value := range config { + clone[key] = value + } + return clone +} + +func hasAnyKey(config map[string]any, keys ...string) bool { + for _, key := range keys { + if _, ok := config[key]; ok { + return true + } + } + return false +} + +func copyBillingConfigKey(target map[string]any, source map[string]any, key string) { + if value, ok := source[key]; ok { + target[key] = value + } +} diff --git a/apps/api/internal/store/model_billing_filter_test.go b/apps/api/internal/store/model_billing_filter_test.go new file mode 100644 index 0000000..a16721d --- /dev/null +++ b/apps/api/internal/store/model_billing_filter_test.go @@ -0,0 +1,99 @@ +package store + +import "testing" + +func TestFilterPlatformModelBillingConfigKeepsOnlyImagePricing(t *testing.T) { + model := PlatformModel{ + ModelType: StringList{"image_generate"}, + BillingConfig: map[string]any{ + "text": map[string]any{"basePrice": 0.01}, + "image": map[string]any{"basePrice": 10}, + "image_edit": map[string]any{"basePrice": 12}, + "video": map[string]any{"basePrice": 100}, + }, + BillingConfigOverride: map[string]any{ + "image": map[string]any{"basePrice": 8}, + "video": map[string]any{"basePrice": 80}, + }, + } + + filtered := FilterPlatformModelBillingConfig(model) + + assertHasKeys(t, filtered.BillingConfig, "image") + assertMissingKeys(t, filtered.BillingConfig, "text", "image_edit", "video") + assertHasKeys(t, filtered.BillingConfigOverride, "image") + assertMissingKeys(t, filtered.BillingConfigOverride, "video") +} + +func TestFilterPlatformModelBillingConfigKeepsImageEditOrImageFallback(t *testing.T) { + model := PlatformModel{ + ModelType: StringList{"image_edit"}, + BillingConfig: map[string]any{ + "image": map[string]any{"basePrice": 10}, + "video": map[string]any{"basePrice": 100}, + }, + } + + filtered := FilterPlatformModelBillingConfig(model) + + assertHasKeys(t, filtered.BillingConfig, "image") + assertMissingKeys(t, filtered.BillingConfig, "video") +} + +func TestFilterPlatformModelBillingConfigKeepsVideoPricing(t *testing.T) { + model := PlatformModel{ + ModelType: StringList{"video_generate", "image_to_video"}, + BillingConfig: map[string]any{ + "image": map[string]any{"basePrice": 10}, + "video": map[string]any{"basePrice": 100}, + "videoBase": 100, + }, + } + + filtered := FilterPlatformModelBillingConfig(model) + + assertHasKeys(t, filtered.BillingConfig, "video", "videoBase") + assertMissingKeys(t, filtered.BillingConfig, "image") +} + +func TestFilterPlatformModelBillingConfigKeepsTextFlatPricing(t *testing.T) { + model := PlatformModel{ + ModelType: StringList{"text_generate"}, + BillingConfig: map[string]any{ + "textInputPer1k": 0.01, + "textOutputPer1k": 0.02, + "text_total": map[string]any{ + "basePrice": 0.01, + "formulaConfig": map[string]any{"inputTokenPrice": 0.01, "outputTokenPrice": 0.02}, + "dynamicWeight": map[string]any{"cached": 0.5}, + "dimensionSchema": map[string]any{"unit": "1k_tokens"}, + }, + "inputTokenPrice": 0.01, + "outputTokenPrice": 0.02, + "image": map[string]any{"basePrice": 10}, + }, + } + + filtered := FilterPlatformModelBillingConfig(model) + + assertHasKeys(t, filtered.BillingConfig, "textInputPer1k", "textOutputPer1k", "text_total", "inputTokenPrice", "outputTokenPrice") + assertMissingKeys(t, filtered.BillingConfig, "image") +} + +func assertHasKeys(t *testing.T, value map[string]any, keys ...string) { + t.Helper() + for _, key := range keys { + if _, ok := value[key]; !ok { + t.Fatalf("expected key %q in %#v", key, value) + } + } +} + +func assertMissingKeys(t *testing.T, value map[string]any, keys ...string) { + t.Helper() + for _, key := range keys { + if _, ok := value[key]; ok { + t.Fatalf("expected key %q to be filtered from %#v", key, value) + } + } +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 4609bf2..b4d92a4 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -79,6 +79,8 @@ WHERE resource_type = 'platform_model' } func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput) (PlatformModel, error) { + input.ModelName = strings.TrimSpace(input.ModelName) + input.ProviderModelName = strings.TrimSpace(input.ProviderModelName) base, err := s.lookupBaseModel(ctx, q, input.BaseModelID, input.CanonicalModelKey, input.ModelName) if err != nil && !IsNotFound(err) { return PlatformModel{}, err @@ -93,6 +95,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, if input.ModelName == "" { input.ModelName = base.ProviderModelName } + if input.ProviderModelName == "" { + input.ProviderModelName = input.ModelName + } if input.DisplayName == "" { input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName) } @@ -153,19 +158,20 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, var modelTypeBytes []byte err = q.QueryRow(ctx, ` INSERT INTO platform_models ( - platform_id, base_model_id, model_name, model_alias, model_type, display_name, + platform_id, base_model_id, model_name, provider_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, runtime_policy_set_id, runtime_policy_override, enabled ) VALUES ( - $1::uuid, $2::uuid, $3, NULLIF($4, ''), $5::jsonb, $6, - $7::jsonb, $8::jsonb, $9, $10::numeric, - NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, - NULLIF($17, '')::uuid, $18::jsonb, true + $1::uuid, $2::uuid, $3, NULLIF($4, ''), NULLIF($5, ''), $6::jsonb, $7, + $8::jsonb, $9::jsonb, $10, $11::numeric, + NULLIF($12, '')::uuid, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb, + NULLIF($18, '')::uuid, $19::jsonb, true ) ON CONFLICT (platform_id, model_name) DO UPDATE SET base_model_id = EXCLUDED.base_model_id, + provider_model_name = EXCLUDED.provider_model_name, model_alias = EXCLUDED.model_alias, display_name = EXCLUDED.display_name, capability_override = EXCLUDED.capability_override, @@ -183,7 +189,7 @@ SET base_model_id = EXCLUDED.base_model_id, 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, + COALESCE(NULLIF(provider_model_name, ''), 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, permission_config, retry_policy, rate_limit_policy, COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, @@ -191,6 +197,7 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ input.PlatformID, baseID, input.ModelName, + input.ProviderModelName, input.ModelAlias, string(modelTypeJSON), input.DisplayName, @@ -211,6 +218,7 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ &model.PlatformID, &model.BaseModelID, &model.ModelName, + &model.ProviderModelName, &model.ModelAlias, &modelTypeBytes, &model.DisplayName, diff --git a/apps/api/internal/store/postgres.go b/apps/api/internal/store/postgres.go index 84659b9..fc8729b 100644 --- a/apps/api/internal/store/postgres.go +++ b/apps/api/internal/store/postgres.go @@ -133,6 +133,7 @@ type PlatformModel struct { Provider string `json:"provider,omitempty"` PlatformName string `json:"platformName,omitempty"` ModelName string `json:"modelName"` + ProviderModelName string `json:"providerModelName,omitempty"` ModelAlias string `json:"modelAlias,omitempty"` ModelType StringList `json:"modelType"` DisplayName string `json:"displayName"` @@ -691,7 +692,7 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo rows, err := s.pool.Query(ctx, ` SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name, - m.model_name, COALESCE(m.model_alias, ''), m.model_type, m.display_name, + m.model_name, COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), COALESCE(m.model_alias, ''), m.model_type, m.display_name, m.capability_override, m.capabilities, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''), m.billing_config_override, m.billing_config, m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, ''), m.runtime_policy_override, @@ -724,6 +725,7 @@ ORDER BY m.model_type ASC, m.model_name ASC`, args...) &model.Provider, &model.PlatformName, &model.ModelName, + &model.ProviderModelName, &model.ModelAlias, &modelTypeBytes, &model.DisplayName, diff --git a/apps/api/internal/store/pricing_rules.go b/apps/api/internal/store/pricing_rules.go index 5aab217..89991dc 100644 --- a/apps/api/internal/store/pricing_rules.go +++ b/apps/api/internal/store/pricing_rules.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "strconv" "strings" "github.com/jackc/pgx/v5" @@ -182,7 +183,7 @@ func (s *Store) PricingRuleSetBillingConfig(ctx context.Context, id string) (map return nil, nil } rows, err := s.pool.Query(ctx, ` -SELECT resource_type, base_price::float8, dynamic_weight +SELECT resource_type, base_price::float8, dynamic_weight, formula_config FROM model_pricing_rules WHERE rule_set_id = $1::uuid AND status = 'active' @@ -197,15 +198,31 @@ ORDER BY priority ASC, resource_type ASC`, id) var resourceType string var basePrice float64 var dynamicWeightBytes []byte - if err := rows.Scan(&resourceType, &basePrice, &dynamicWeightBytes); err != nil { + var formulaConfigBytes []byte + if err := rows.Scan(&resourceType, &basePrice, &dynamicWeightBytes, &formulaConfigBytes); err != nil { return nil, err } dynamicWeight := decodeObject(dynamicWeightBytes) + formulaConfig := decodeObject(formulaConfigBytes) switch resourceType { case "text_input": config["textInputPer1k"] = basePrice case "text_output": config["textOutputPer1k"] = basePrice + case "text_total": + inputPrice := basePrice + if value, ok := pricingRuleNumberFromKeys(formulaConfig, "inputTokenPrice", "input_token_price", "textInputPer1k", "text_input"); ok { + inputPrice = value + } + config["textInputPer1k"] = inputPrice + if outputPrice, ok := pricingRuleNumberFromKeys(formulaConfig, "outputTokenPrice", "output_token_price", "textOutputPer1k", "text_output"); ok { + config["textOutputPer1k"] = outputPrice + } + resourceConfig := pricingResourceConfig(basePrice, dynamicWeight) + if len(formulaConfig) > 0 { + resourceConfig["formulaConfig"] = formulaConfig + } + config["text_total"] = resourceConfig case "image": config["imageBase"] = basePrice config["image"] = pricingResourceConfig(basePrice, dynamicWeight) @@ -225,6 +242,45 @@ ORDER BY priority ASC, resource_type ASC`, id) return config, nil } +func pricingRuleNumberFromKeys(config map[string]any, keys ...string) (float64, bool) { + if len(config) == 0 { + return 0, false + } + for _, key := range keys { + if value, ok := pricingRuleNumberValue(config[key]); ok { + return value, true + } + } + return 0, false +} + +func pricingRuleNumberValue(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case int32: + return float64(typed), true + case json.Number: + number, err := typed.Float64() + return number, err == nil + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, false + } + number, err := strconv.ParseFloat(trimmed, 64) + return number, err == nil + default: + return 0, false + } +} + func pricingResourceConfig(basePrice float64, dynamicWeight map[string]any) map[string]any { config := map[string]any{"basePrice": basePrice} if len(dynamicWeight) > 0 { diff --git a/apps/api/internal/store/runtime_types.go b/apps/api/internal/store/runtime_types.go index ad968b7..9dc32f8 100644 --- a/apps/api/internal/store/runtime_types.go +++ b/apps/api/internal/store/runtime_types.go @@ -15,6 +15,7 @@ type CreatePlatformModelInput struct { BaseModelID string `json:"baseModelId"` CanonicalModelKey string `json:"canonicalModelKey"` ModelName string `json:"modelName"` + ProviderModelName string `json:"providerModelName"` ModelAlias string `json:"modelAlias"` ModelType StringList `json:"modelType"` DisplayName string `json:"displayName"` diff --git a/apps/api/internal/store/tasks_runtime.go b/apps/api/internal/store/tasks_runtime.go index 0406047..42db75f 100644 --- a/apps/api/internal/store/tasks_runtime.go +++ b/apps/api/internal/store/tasks_runtime.go @@ -11,13 +11,35 @@ import ( "github.com/jackc/pgx/v5/pgconn" ) -func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]GatewayTask, error) { - if limit <= 0 { - limit = 50 +type TaskListFilter struct { + Query string + ModelType string + CreatedFrom *time.Time + CreatedTo *time.Time + Page int + PageSize int +} + +type TaskListResult struct { + Items []GatewayTask + Total int + Page int + PageSize int +} + +func (s *Store) ListTasks(ctx context.Context, user *auth.User, filter TaskListFilter) (TaskListResult, error) { + page := filter.Page + if page <= 0 { + page = 1 } - if limit > 100 { - limit = 100 + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = 50 } + if pageSize > 100 { + pageSize = 100 + } + offset := (page - 1) * pageSize gatewayUserID := localGatewayUserID(user) apiKeyID := "" userID := "" @@ -26,11 +48,22 @@ func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]Ga userID = strings.TrimSpace(user.ID) } if gatewayUserID == "" && userID == "" { - return nil, ErrLocalUserRequired + return TaskListResult{}, ErrLocalUserRequired } - rows, err := s.pool.Query(ctx, ` -SELECT `+gatewayTaskColumns+` -FROM gateway_tasks + queryPattern := "" + if query := strings.TrimSpace(filter.Query); query != "" { + queryPattern = "%" + query + "%" + } + args := []any{ + gatewayUserID, + userID, + apiKeyID, + queryPattern, + strings.TrimSpace(filter.ModelType), + nullableTaskListTime(filter.CreatedFrom), + nullableTaskListTime(filter.CreatedTo), + } + whereSQL := ` WHERE ( ( NULLIF($1, '')::uuid IS NOT NULL @@ -46,10 +79,45 @@ WHERE ( NULLIF($3, '') IS NULL OR api_key_id = $3 ) + AND ( + NULLIF($4, '') IS NULL + OR id::text ILIKE $4 + OR COALESCE(request_id, '') ILIKE $4 + OR kind ILIKE $4 + OR model ILIKE $4 + OR COALESCE(requested_model, '') ILIKE $4 + OR COALESCE(resolved_model, '') ILIKE $4 + OR COALESCE(api_key_id, '') ILIKE $4 + OR COALESCE(api_key_name, '') ILIKE $4 + OR COALESCE(api_key_prefix, '') ILIKE $4 + OR status ILIKE $4 + OR COALESCE(model_type, '') ILIKE $4 + ) + AND ( + NULLIF($5, '') IS NULL + OR model_type = $5 + ) + AND ( + $6::timestamptz IS NULL + OR created_at >= $6::timestamptz + ) + AND ( + $7::timestamptz IS NULL + OR created_at <= $7::timestamptz + )` + var total int + if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks `+whereSQL, args...).Scan(&total); err != nil { + return TaskListResult{}, err + } + queryArgs := append(args, pageSize, offset) + rows, err := s.pool.Query(ctx, ` +SELECT `+gatewayTaskColumns+` +FROM gateway_tasks +`+whereSQL+` ORDER BY created_at DESC -LIMIT $4`, gatewayUserID, userID, apiKeyID, limit) +LIMIT $8 OFFSET $9`, queryArgs...) if err != nil { - return nil, err + return TaskListResult{}, err } defer rows.Close() @@ -57,11 +125,26 @@ LIMIT $4`, gatewayUserID, userID, apiKeyID, limit) for rows.Next() { task, err := scanGatewayTask(rows) if err != nil { - return nil, err + return TaskListResult{}, err } items = append(items, task) } - return items, rows.Err() + if err := rows.Err(); err != nil { + return TaskListResult{}, err + } + return TaskListResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func nullableTaskListTime(value *time.Time) any { + if value == nil { + return nil + } + return *value } func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error { diff --git a/apps/api/migrations/0024_platform_model_provider_model_name.sql b/apps/api/migrations/0024_platform_model_provider_model_name.sql new file mode 100644 index 0000000..eace7f9 --- /dev/null +++ b/apps/api/migrations/0024_platform_model_provider_model_name.sql @@ -0,0 +1,6 @@ +ALTER TABLE IF EXISTS platform_models + ADD COLUMN IF NOT EXISTS provider_model_name text; + +UPDATE platform_models +SET provider_model_name = model_name +WHERE provider_model_name IS NULL OR btrim(provider_model_name) = ''; diff --git a/apps/web/package.json b/apps/web/package.json index 3a369ce..bcee7f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,9 +23,11 @@ "@streamdown/math": "^1.0.2", "@streamdown/mermaid": "^1.0.2", "@vitejs/plugin-react": "^5.0.0", + "antd": "^5.29.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dayjs": "^1.11.20", "katex": "^0.16.45", "lucide-react": "^1.14.0", "react": "^19.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b02b89e..e99c33d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ import type { GatewayTenant, GatewayUser, IntegrationPlatform, + ModelCatalogResponse, PlatformModel, PricingRule, PricingRuleSet, @@ -42,6 +43,7 @@ import { listApiKeys, listBaseModels, listCatalogProviders, + listModelCatalog, listModels, listPlayableApiKeys, listPlayableModels, @@ -87,6 +89,8 @@ import { pathForPage, pathForPlaygroundMode, pathForWorkspaceSection, + pathForWorkspaceTaskQuery, + workspaceTaskQueryKey, type AppRouteState, } from './routing'; import type { @@ -103,6 +107,7 @@ import type { PlatformWithModelsInput, RegisterForm, TaskForm, + WorkspaceTaskQuery, WorkspaceSection, } from './types'; @@ -111,6 +116,7 @@ type DataKey = | 'publicCatalog' | 'playgroundApiKeys' | 'playgroundModels' + | 'modelCatalog' | 'platforms' | 'models' | 'providers' @@ -131,6 +137,7 @@ export function App() { const [activePage, setActivePage] = useState(initialRoute.activePage); const [adminSection, setAdminSection] = useState(initialRoute.adminSection); const [workspaceSection, setWorkspaceSection] = useState(initialRoute.workspaceSection); + const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState(initialRoute.workspaceTaskQuery); const [apiDocSection, setApiDocSection] = useState(initialRoute.apiDocSection); const [playgroundMode, setPlaygroundMode] = useState(initialRoute.playgroundMode); const [token, setToken] = useState(readStoredAccessToken); @@ -141,6 +148,11 @@ export function App() { const [health, setHealth] = useState(null); const [platforms, setPlatforms] = useState([]); const [models, setModels] = useState([]); + const [modelCatalog, setModelCatalog] = useState({ + items: [], + filters: { capabilities: [], providers: [] }, + summary: { modelCount: 0, sourceCount: 0 }, + }); const [playgroundModels, setPlaygroundModels] = useState([]); const [providers, setProviders] = useState([]); const [baseModels, setBaseModels] = useState([]); @@ -160,12 +172,15 @@ export function App() { const [taskForm, setTaskForm] = useState({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' }); const [taskResult, setTaskResult] = useState(null); const [tasks, setTasks] = useState([]); + const [taskTotal, setTaskTotal] = useState(0); const [coreState, setCoreState] = useState('idle'); const [coreMessage, setCoreMessage] = useState(''); const [state, setState] = useState('idle'); const [error, setError] = useState(''); const loadedDataKeysRef = useRef(new Set()); const loadingDataKeysRef = useRef(new Set()); + const loadedTaskQueryKeyRef = useRef(''); + const currentTaskQueryKeyRef = useRef(''); const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({ setBaseModels, setCoreMessage, @@ -185,13 +200,15 @@ export function App() { setRuntimePolicySets, token, }); + const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery); + currentTaskQueryKeyRef.current = taskListRequestKey; useEffect(() => { void ensureData(['health']); }, []); useEffect(() => { void ensureRouteData(token); - }, [activePage, adminSection, workspaceSection, token]); + }, [activePage, adminSection, taskListRequestKey, workspaceSection, token]); useEffect(() => { function handlePopState() { applyRoute(parseAppRoute()); @@ -223,6 +240,7 @@ export function App() { accessRules, apiKeys, baseModels, + modelCatalog, models, platforms, pricingRules, @@ -235,7 +253,7 @@ export function App() { tenants, userGroups, users, - }), [accessRules, apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]); + }), [accessRules, apiKeys, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -246,6 +264,10 @@ export function App() { } async function ensureRouteData(nextToken = token, force = false) { + if (activePage === 'workspace' && workspaceSection === 'tasks' && loadedTaskQueryKeyRef.current !== taskListRequestKey) { + loadedDataKeysRef.current.delete('tasks'); + loadingDataKeysRef.current.delete('tasks'); + } await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force); } @@ -294,6 +316,9 @@ export function App() { case 'models': setModels((await listModels(nextToken)).items); return; + case 'modelCatalog': + setModelCatalog(await listModelCatalog(nextToken)); + return; case 'playgroundModels': setPlaygroundModels((await listPlayableModels(nextToken)).items); return; @@ -332,7 +357,14 @@ export function App() { setUserGroups((await listUserGroups(nextToken)).items); return; case 'tasks': - setTasks((await listTasks(nextToken)).items); + { + const requestKey = taskListRequestKey; + const response = await listTasks(nextToken, workspaceTaskQuery); + if (requestKey !== currentTaskQueryKeyRef.current) return; + setTasks(response.items); + setTaskTotal(response.total ?? response.items.length); + loadedTaskQueryKeyRef.current = requestKey; + } return; case 'accessRules': setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys' @@ -420,6 +452,7 @@ export function App() { 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]); + invalidateDataKeys('modelCatalog'); setCoreState('ready'); setCoreMessage(input.platformId ? `平台已更新,当前绑定 ${input.models.length} 个模型。` @@ -438,6 +471,7 @@ export function App() { await deletePlatform(token, platformId); setPlatforms((current) => current.filter((item) => item.id !== platformId)); setModels((current) => current.filter((item) => item.platformId !== platformId)); + invalidateDataKeys('modelCatalog'); setCoreState('ready'); setCoreMessage('平台已删除。'); } catch (err) { @@ -514,6 +548,7 @@ export function App() { try { const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input); setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]); + invalidateDataKeys('modelCatalog'); setCoreState('ready'); setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。'); } catch (err) { @@ -531,6 +566,7 @@ export function App() { 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)); + invalidateDataKeys('modelCatalog'); invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage('用户组已删除。'); @@ -569,7 +605,7 @@ export function App() { try { const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); - invalidateDataKeys('playgroundModels'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); } catch (err) { @@ -585,7 +621,7 @@ export function App() { try { await deleteAccessRule(token, ruleId); setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); - invalidateDataKeys('playgroundModels'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限规则已删除。'); } catch (err) { @@ -601,7 +637,7 @@ export function App() { try { const response = await batchAccessRules(token, input); setAccessRules(response.items); - invalidateDataKeys('playgroundModels'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限已更新。'); } catch (err) { @@ -653,6 +689,7 @@ export function App() { setState('idle'); setPlatforms([]); setModels([]); + setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } }); setPlaygroundModels([]); setProviders([]); setBaseModels([]); @@ -670,6 +707,7 @@ export function App() { setSelectedPlaygroundApiKeyId(''); setTaskResult(null); setTasks([]); + setTaskTotal(0); setCoreMessage(''); navigatePath('/'); } @@ -680,7 +718,7 @@ export function App() { } function currentRouteState(): AppRouteState { - return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection }; + return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection, workspaceTaskQuery }; } function applyRoute(route: AppRouteState) { @@ -689,10 +727,11 @@ export function App() { setApiDocSection(route.apiDocSection); setPlaygroundMode(route.playgroundMode); setWorkspaceSection(route.workspaceSection); + setWorkspaceTaskQuery(route.workspaceTaskQuery); } function navigatePath(path: string) { - if (window.location.pathname !== path) { + if (`${window.location.pathname}${window.location.search}` !== path) { window.history.pushState(null, '', path); } applyRoute(parseAppRoute(path)); @@ -710,6 +749,10 @@ export function App() { navigatePath(pathForWorkspaceSection(section)); } + function navigateWorkspaceTaskQuery(query: WorkspaceTaskQuery) { + navigatePath(pathForWorkspaceTaskQuery(query)); + } + function navigateApiDocSection(section: ApiDocSection) { navigatePath(pathForApiDocSection(section)); } @@ -768,11 +811,14 @@ export function App() { message={coreMessage} section={workspaceSection} state={coreState} + taskQuery={workspaceTaskQuery} + taskTotal={taskTotal} onBatchAccessRules={batchSaveAPIKeyAccessRules} onDeleteApiKey={removeAPIKey} onApiKeyFormChange={setApiKeyForm} onSectionChange={navigateWorkspaceSection} onSubmitApiKey={submitAPIKey} + onTaskQueryChange={navigateWorkspaceTaskQuery} onUseApiKeyForPlayground={useApiKeyForPlayground} /> ) : ( @@ -877,6 +923,7 @@ function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, curre if (!existing) return input; return { ...input, + providerModelName: input.providerModelName ?? existing.providerModelName, discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined, pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId, rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy, @@ -933,7 +980,11 @@ function dataKeysForRoute( isAuthenticated: boolean, ): DataKey[] { if (activePage === 'playground') return isAuthenticated ? ['playgroundModels', 'playgroundApiKeys'] : []; - if (activePage === 'models') return ['publicCatalog']; + if (activePage === 'models') { + return isAuthenticated + ? ['modelCatalog'] + : ['publicCatalog']; + } if (activePage === 'home' || activePage === 'docs') return []; if (!isAuthenticated) return []; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 79af9e0..8e66cad 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -16,6 +16,7 @@ import type { GatewayUserUpsertRequest, IntegrationPlatform, ListResponse, + ModelCatalogResponse, PlatformModel, PlayableGatewayApiKey, PricingRule, @@ -27,7 +28,7 @@ import type { UserGroup, UserGroupUpsertRequest, } from '@easyai-ai-gateway/contracts'; -import type { PlatformCreateInput, PlatformModelBindingInput } from './types'; +import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; @@ -76,6 +77,10 @@ export async function listPlayableModels(token: string): Promise>('/api/v1/models', { token }); } +export async function listModelCatalog(token: string): Promise { + return request('/api/v1/model-catalog', { token }); +} + export async function listPublicCatalogProviders(): Promise> { return request>('/api/v1/public/catalog/providers', { auth: false }); } @@ -582,8 +587,16 @@ export async function getTask(token: string, taskId: string): Promise(`/api/v1/tasks/${taskId}`, { token }); } -export async function listTasks(token: string, limit = 50): Promise> { - return request>(`/api/v1/tasks?limit=${encodeURIComponent(String(limit))}`, { token }); +export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise> { + const search = new URLSearchParams({ + page: String(query.page), + pageSize: String(query.pageSize), + }); + if (query.query) search.set('q', query.query); + if (query.modelType) search.set('modelType', query.modelType); + if (query.createdFrom) search.set('createdFrom', query.createdFrom); + if (query.createdTo) search.set('createdTo', query.createdTo); + return request>(`/api/v1/tasks?${search.toString()}`, { token }); } export function resolveApiAssetUrl(src: string) { diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index 93e8e51..3c0028e 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -7,6 +7,7 @@ import type { GatewayTenant, GatewayUser, IntegrationPlatform, + ModelCatalogResponse, PlatformModel, PricingRule, PricingRuleSet, @@ -19,6 +20,7 @@ export interface ConsoleData { accessRules: GatewayAccessRule[]; apiKeys: GatewayApiKey[]; baseModels: BaseModelCatalogItem[]; + modelCatalog: ModelCatalogResponse; models: PlatformModel[]; platforms: IntegrationPlatform[]; pricingRules: PricingRule[]; diff --git a/apps/web/src/components/ui/date-time-picker.tsx b/apps/web/src/components/ui/date-time-picker.tsx index 56e260f..3674538 100644 --- a/apps/web/src/components/ui/date-time-picker.tsx +++ b/apps/web/src/components/ui/date-time-picker.tsx @@ -1,5 +1,11 @@ import { useMemo, useState } from 'react'; import { format } from 'date-fns'; +import ConfigProvider from 'antd/es/config-provider'; +import DatePicker from 'antd/es/date-picker'; +import zhCN from 'antd/es/locale/zh_CN'; +import dayjs, { type Dayjs } from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import 'dayjs/locale/zh-cn'; import { CalendarIcon, X } from 'lucide-react'; import { Button } from './button'; import { Calendar } from './calendar'; @@ -7,7 +13,11 @@ import { Input } from './input'; import { Label } from './label'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; +dayjs.extend(customParseFormat); +dayjs.locale('zh-cn'); + export function DateTimePicker(props: { + clearLabel?: string; disabled?: boolean; placeholder?: string; value: string; @@ -54,7 +64,7 @@ export function DateTimePicker(props: { 时间 updateTime(event.target.value)} /> - @@ -63,9 +73,68 @@ export function DateTimePicker(props: { ); } +export function DateTimeRangePicker(props: { + disabled?: boolean; + from: string; + fromPlaceholder?: string; + to: string; + toPlaceholder?: string; + onChange: (value: { from: string; to: string }) => void; +}) { + const value = useMemo<[Dayjs | null, Dayjs | null] | null>(() => { + const from = parseDayjsDateTime(props.from); + const to = parseDayjsDateTime(props.to); + return from || to ? [from, to] : null; + }, [props.from, props.to]); + + return ( + + { + props.onChange({ + from: nextValue?.[0] ? nextValue[0].format(rangeDateTimeFormat) : '', + to: nextValue?.[1] ? nextValue[1].format(rangeDateTimeFormat) : '', + }); + }} + /> + + ); +} + +const rangeDateTimeFormat = 'YYYY-MM-DD HH:mm:ss'; +const rangeParseFormats = [rangeDateTimeFormat, 'YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD']; +const dateRangePickerTheme = { + token: { + colorPrimary: '#18181b', + colorPrimaryHover: '#27272a', + colorBorder: '#e4e4e7', + colorText: '#27272a', + colorTextPlaceholder: '#71717a', + colorBgContainer: '#ffffff', + colorBgElevated: '#ffffff', + colorFillSecondary: '#f4f4f5', + borderRadius: 6, + controlHeight: 36, + fontFamily: 'inherit', + fontSize: 14, + }, +}; + function parseLocalDateTime(value: string) { if (!value) return undefined; - const date = new Date(value); + const normalized = value.includes('T') ? value : value.replace(' ', 'T'); + const date = new Date(normalized); return Number.isNaN(date.getTime()) ? undefined : date; } @@ -82,6 +151,14 @@ function formatTimeValue(date: Date) { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } +function parseDayjsDateTime(value: string): Dayjs | null { + if (!value) return null; + const parsed = dayjs(value, rangeParseFormats, true); + if (parsed.isValid()) return parsed; + const fallback = dayjs(value); + return fallback.isValid() ? fallback : null; +} + function pad(value: number) { return String(value).padStart(2, '0'); } diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx index 0010c6c..6ccb1bb 100644 --- a/apps/web/src/components/ui/table.tsx +++ b/apps/web/src/components/ui/table.tsx @@ -1,9 +1,40 @@ import * as React from 'react'; import { cn } from '../../lib/utils'; -export function Table(props: React.HTMLAttributes) { +export type TableDensity = 'standard' | 'compact'; + +export interface TableProps extends React.HTMLAttributes { + density?: TableDensity; +} + +export function Table(props: TableProps) { + const { className, density = 'standard', ...rest } = props; + return
; +} + +export function TableViewportLayout(props: React.HTMLAttributes) { const { className, ...rest } = props; - return
; + return
; +} + +export function TableToolbar(props: React.HTMLAttributes) { + const { className, ...rest } = props; + return
; +} + +export function TableSummary(props: React.HTMLAttributes) { + const { className, ...rest } = props; + return
; +} + +export function TableFooter(props: React.HTMLAttributes) { + const { className, ...rest } = props; + return
; +} + +export function TablePageActions(props: React.HTMLAttributes) { + const { className, ...rest } = props; + return
; } export function TableRow(props: React.HTMLAttributes) { @@ -11,14 +42,14 @@ export function TableRow(props: React.HTMLAttributes) { return
; } -export function TableHead(props: React.HTMLAttributes) { +export function TableHead(props: React.HTMLAttributes) { const { className, ...rest } = props; - return ; + return
; } -export function TableCell(props: React.HTMLAttributes) { +export function TableCell(props: React.HTMLAttributes) { const { className, ...rest } = props; - return ; + return
; } export function EmptyState(props: { title: string; description?: string }) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a5ab2c8..f88f480 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; import 'katex/dist/katex.min.css'; import 'streamdown/styles.css'; +import 'antd/dist/reset.css'; import './styles.css'; createRoot(document.getElementById('root')!).render( diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 9f451ad..ee79f34 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -1,156 +1,37 @@ -import { useMemo, useState } from 'react'; -import { Boxes, Search } from 'lucide-react'; -import type { - BaseModelCatalogItem, - BillingConfig, - CatalogProvider, - PlatformModel, -} from '@easyai-ai-gateway/contracts'; +import { useEffect, useMemo, useState } from 'react'; +import { Boxes, Check, Search, X } from 'lucide-react'; +import type { ModelCatalogFilterOption, ModelCatalogItem, ModelCatalogPermission } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; -import { Badge, Card, CardContent, Input } from '../components/ui'; -import { stableModelAlias } from './admin/platform-form'; - -type ModelListItem = { - id: string; - providerKey: string; - platformName?: string; - modelName: string; - modelAlias?: string; - modelType: string[]; - displayName: string; - capabilities?: Record; - pricingMode: string; - billingConfig?: BillingConfig; - billingConfigOverride?: BillingConfig; - enabled: boolean; -}; - -const capabilityFilters = [ - { value: 'all', label: '全部' }, - { value: 'chat', label: '对话' }, - { value: 'image', label: '绘图' }, - { value: 'video', label: '视频' }, - { value: 'audio', label: '音频' }, - { value: 'embedding', label: 'Embedding' }, -]; - -const publicProviders: CatalogProvider[] = [ - { - id: 'public-openai', - providerKey: 'openai', - code: 'openai', - displayName: 'OpenAI', - providerType: 'openai', - source: 'server-main.integration-platform', - status: 'active', - createdAt: '', - updatedAt: '', - }, - { - id: 'public-gemini', - providerKey: 'gemini', - code: 'google-gemini', - displayName: 'Google Gemini', - providerType: 'gemini', - iconPath: 'https://static.51easyai.com/gemini-color.png', - source: 'server-main.integration-platform', - status: 'active', - createdAt: '', - updatedAt: '', - }, -]; - -const publicModels: PlatformModel[] = [ - { - id: 'public-openai-gpt-4o-mini', - platformId: 'public-openai', - provider: 'OpenAI', - platformName: 'OpenAI Simulation', - modelName: 'gpt-4o-mini', - modelAlias: 'gpt-4o-mini', - modelType: ['text_generate'], - displayName: 'gpt-4o-mini', - capabilities: { multimodal: true }, - pricingMode: 'inherit', - enabled: true, - createdAt: '', - updatedAt: '', - }, - { - id: 'public-openai-gpt-image-1', - platformId: 'public-openai', - provider: 'OpenAI', - platformName: 'OpenAI Simulation', - modelName: 'gpt-image-1', - modelAlias: 'gpt-image-1', - modelType: ['image_generate', 'image_edit'], - displayName: 'gpt-image-1', - capabilities: { imageEdit: true }, - pricingMode: 'inherit', - enabled: true, - createdAt: '', - updatedAt: '', - }, - { - id: 'public-gemini-flash', - platformId: 'public-gemini', - provider: 'Gemini', - platformName: 'Gemini Simulation', - modelName: 'gemini-2.0-flash', - modelAlias: 'gemini-2.0-flash', - modelType: ['text_generate'], - displayName: 'gemini-2.0-flash', - capabilities: { multimodal: true, vision: true }, - pricingMode: 'inherit_discount', - enabled: true, - createdAt: '', - updatedAt: '', - }, -]; +import { Card, CardContent, Input } from '../components/ui'; export function ModelsPage(props: { data: ConsoleData }) { + const catalog = props.data.modelCatalog; const [query, setQuery] = useState(''); const [provider, setProvider] = useState('all'); const [capability, setCapability] = useState('all'); - const sourceProviders = props.data.providers.length ? props.data.providers : publicProviders; - const providerMap = useMemo(() => buildProviderMap(sourceProviders), [sourceProviders]); - const sourceModels = useMemo(() => { - if (props.data.models.length) { - return props.data.models.map(modelFromPlatform); - } - if (props.data.baseModels.length) { - return props.data.baseModels.map(modelFromBaseModel); - } - return publicModels.map(modelFromPlatform); - }, [props.data.baseModels, props.data.models]); + const providerOptions = catalog.filters.providers.length ? catalog.filters.providers : [{ value: 'all', label: '全部', count: catalog.items.length }]; + const capabilityOptions = catalog.filters.capabilities.length ? catalog.filters.capabilities : [{ value: 'all', label: '全部', count: catalog.items.length }]; - const providerOptions = useMemo(() => { - const options = new Map(); - sourceProviders - .filter((item) => item.status !== 'hidden') - .forEach((item) => options.set(item.providerKey, item.displayName)); - sourceModels.forEach((model) => { - if (!options.has(model.providerKey)) { - options.set(model.providerKey, providerMap.get(model.providerKey)?.displayName ?? model.providerKey); - } - }); - return [ - { value: 'all', label: '全部' }, - ...Array.from(options.entries()) - .sort((a, b) => a[1].localeCompare(b[1])) - .map(([value, label]) => ({ value, label })), - ]; - }, [providerMap, sourceModels, sourceProviders]); + useEffect(() => { + if (!providerOptions.some((item) => item.value === provider)) setProvider('all'); + }, [provider, providerOptions]); + + useEffect(() => { + if (!capabilityOptions.some((item) => item.value === capability)) setCapability('all'); + }, [capability, capabilityOptions]); const filteredModels = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); - return sourceModels.filter((model) => { - const matchedProvider = provider === 'all' || model.providerKey === provider; - const matchedCapability = modelMatchesCapability(model.modelType, capability); - const matchedQuery = [ - model.modelName, - model.modelAlias, + return catalog.items.filter((model) => { + const matchedProvider = provider === 'all' || model.providerKeys.includes(provider); + const matchedCapability = capability === 'all' || modelMatchesCapability(model, capability); + const matchedQuery = !normalizedQuery || [ + model.alias, model.displayName, + model.modelName, + model.description, + ...model.providers.map((item) => item.name), + ...model.capabilityTags, ] .filter(Boolean) .join(' ') @@ -158,14 +39,14 @@ export function ModelsPage(props: { data: ConsoleData }) { .includes(normalizedQuery); return matchedProvider && matchedCapability && matchedQuery; }); - }, [capability, provider, query, sourceModels]); + }, [capability, catalog.items, provider, query]); return (
@@ -230,104 +113,118 @@ function FilterGroup(props: { ); } -function ModelCard(props: { model: ModelListItem; provider?: CatalogProvider }) { - const tags = tagsForModel(props.model); - const providerName = props.provider?.displayName ?? props.model.providerKey; +function FilterIcon(props: { item: ModelCatalogFilterOption }) { + if (props.item.iconPath) { + return ( + + + + ); + } + if (props.item.value === 'all') return null; + return {providerInitials(props.item.label)}; +} + +function ModelCard(props: { model: ModelCatalogItem }) { + const description = props.model.description || '暂无模型描述'; return (
- -
- {props.model.displayName || props.model.modelName} - {providerName} · {props.model.platformName ?? props.provider?.code ?? 'catalog'} + +
+ {props.model.displayName || props.model.alias} +

{description}

- - {props.model.enabled ? '启用' : '停用'} -
-

{props.model.modelAlias || props.model.modelName}

-
- {tags.map((tag) => {tag})} -
-
- {priceLabel(props.model)} - 查看详情 + +
+
+ {props.model.capabilityTags.map((tag) => {tag})} +
+ +
+
+
+
{props.model.source.label}
+
+
+
折扣率
+
{props.model.discount.label}
+
+
+
限流
+
{props.model.rateLimits.label}
+
+
+
权限要求
+
+ +
+
+
+
模型定价
+
{props.model.pricing.lines.join(';')}
+
+
); } -function ProviderIcon(props: { provider?: CatalogProvider; label?: string }) { - const label = props.label ?? props.provider?.displayName ?? props.provider?.providerKey ?? 'AI'; - if (props.provider?.iconPath) { +function PermissionValue(props: { permission: ModelCatalogPermission }) { + const allowGroups = props.permission.allowGroups ?? []; + const denyGroups = props.permission.denyGroups ?? []; + if (!allowGroups.length && !denyGroups.length) { + return {props.permission.label}; + } + return ( + + {allowGroups.map((group) => ( + + + ))} + {denyGroups.map((group) => ( + + + ))} + + ); +} + +function ModelIcon(props: { iconPath?: string; label: string }) { + if (props.iconPath) { return (
- +
); } - return
{providerInitials(label)}
; + return
{providerInitials(props.label)}
; } -function buildProviderMap(providers: CatalogProvider[]) { - const map = new Map(); - providers.forEach((provider) => { - [ - provider.providerKey, - provider.code, - provider.displayName, - stringMetadata(provider.metadata, 'sourceCode'), - ].filter(Boolean).forEach((key) => map.set(normalizeProviderKey(key), provider)); - map.set(provider.providerKey, provider); - }); - return map; +function modelMatchesCapability(model: ModelCatalogItem, capability: string) { + if (model.modelType.some((type) => modelTypeMatchesCapability(type, capability))) return true; + if (capability === 'tools') return model.capabilityTags.includes('工具调用'); + if (capability === 'omni') return model.capabilityTags.includes('全模态'); + return false; } -function modelFromPlatform(model: PlatformModel): ModelListItem { - return { - id: model.id, - providerKey: normalizeProviderKey(model.provider ?? model.platformName ?? ''), - platformName: model.platformName, - modelName: model.modelName, - modelAlias: model.modelAlias, - modelType: model.modelType, - displayName: model.displayName, - capabilities: model.capabilities ?? model.capabilityOverride, - pricingMode: model.pricingMode, - billingConfig: model.billingConfig, - billingConfigOverride: model.billingConfigOverride, - enabled: model.enabled, - }; -} - -function modelFromBaseModel(model: BaseModelCatalogItem): ModelListItem { - return { - id: model.id, - providerKey: model.providerKey, - modelName: model.providerModelName, - modelAlias: stableModelAlias(model), - modelType: model.modelType, - displayName: stableModelAlias(model), - capabilities: model.capabilities, - pricingMode: 'inherit', - billingConfig: model.baseBillingConfig, - enabled: model.status === 'active', - }; -} - -function normalizeProviderKey(value: string) { - const normalized = value.trim().toLowerCase(); - if (!normalized) return 'unknown'; - if (normalized.includes('gemini')) return 'gemini'; - if (normalized.includes('openai')) return 'openai'; - return normalized.replace(/\s+/g, '-'); -} - -function stringMetadata(metadata: Record | undefined, key: string) { - const value = metadata?.[key]; - return typeof value === 'string' ? value : ''; +function modelTypeMatchesCapability(value: string, capability: string) { + const type = value.trim().toLowerCase(); + if (capability === 'chat') return type === 'text_generate' || type === 'chat' || type === 'responses' || type.includes('text'); + if (capability === 'image') return type.includes('image') && !type.includes('video'); + if (capability === 'video') return type.includes('video'); + if (capability === 'audio') return type.includes('audio') || type.includes('speech'); + if (capability === 'embedding') return type === 'text_embedding' || type === 'embedding'; + if (capability === 'tools') return type === 'tools_call'; + if (capability === 'omni') return type === 'omni' || type === 'omni_video'; + return type === capability; } function providerInitials(label: string) { @@ -338,41 +235,3 @@ function providerInitials(label: string) { .slice(0, 2) .toUpperCase() || 'AI'; } - -function tagsForModel(model: ModelListItem) { - const tags = model.modelType.map(capabilityName); - const capabilities = model.capabilities ?? {}; - if (capabilities.multimodal || capabilities.vision) tags.push('多模态'); - if (capabilities.reasoning) tags.push('推理'); - if (model.pricingMode === 'inherit_discount') tags.push('折扣'); - if (model.pricingMode === 'custom') tags.push('自定义价'); - return tags; -} - -function capabilityName(type: string) { - const labels: Record = { - text_generate: '对话', - image_generate: '绘图', - image_edit: '图像编辑', - video_generate: '视频', - image_to_video: '图生视频', - audio_generate: '音频', - }; - return labels[type] ?? capabilityFilters.find((item) => item.value === type)?.label ?? type; -} - -function modelMatchesCapability(modelTypes: string[], capability: string) { - if (capability === 'all') return true; - if (capability === 'chat') return modelTypes.includes('text_generate') || modelTypes.includes('chat'); - if (capability === 'image') return modelTypes.some((type) => type.includes('image')); - if (capability === 'video') return modelTypes.some((type) => type.includes('video')); - return modelTypes.includes(capability); -} - -function priceLabel(model: ModelListItem) { - const config = model.billingConfig ?? model.billingConfigOverride; - if (typeof config?.basePrice === 'number') { - return `${config.basePrice}/${config.unit ?? config.resourceType ?? 'unit'}`; - } - return model.pricingMode === 'inherit' ? '跟随基准定价' : model.pricingMode; -} diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index eedabb4..82b2710 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState, type FormEvent, type ReactNode } from 'react'; -import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react'; +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; +import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react'; import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor'; -import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types'; +import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types'; const tabs = [ { value: 'overview', label: '个人总览', icon: }, @@ -14,6 +14,8 @@ const tabs = [ { value: 'tasks', label: '任务记录', icon: }, ] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>; +const taskPageSizeOptions = [10, 20, 50]; + export function WorkspacePage(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; @@ -23,11 +25,14 @@ export function WorkspacePage(props: { message: string; section: WorkspaceSection; state: LoadState; + taskQuery: WorkspaceTaskQuery; + taskTotal: number; onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSectionChange: (value: WorkspaceSection) => void; onSubmitApiKey: (event: FormEvent) => void | Promise; + onTaskQueryChange: (value: WorkspaceTaskQuery) => void; onUseApiKeyForPlayground: (apiKeyId?: string) => void; }) { return ( @@ -38,7 +43,7 @@ export function WorkspacePage(props: { {props.section === 'overview' && } {props.section === 'billing' && } {props.section === 'apiKeys' && } - {props.section === 'tasks' && } + {props.section === 'tasks' && }
@@ -296,62 +301,319 @@ function ApiKeyPanel(props: { ); } -function TaskPanel(props: { data: ConsoleData }) { - const tasks = useMemo(() => { - const latest = props.data.taskResult; - if (!latest) return props.data.tasks; - return [latest, ...props.data.tasks.filter((item) => item.id !== latest.id)]; - }, [props.data.taskResult, props.data.tasks]); +function TaskPanel(props: { + data: ConsoleData; + query: WorkspaceTaskQuery; + total: number; + onQueryChange: (value: WorkspaceTaskQuery) => void; +}) { + const [localMessage, setLocalMessage] = useState(''); + const [jsonTask, setJsonTask] = useState(null); + const [pageJump, setPageJump] = useState(String(props.query.page)); + const taskQuery = props.query; + const tasks = props.data.tasks; + const taskTypes = useMemo(() => { + const knownTypes = ['text_generate', 'image_generate', 'image_edit', 'video_generate', 'image_to_video']; + const values = [...knownTypes, taskQuery.modelType, ...tasks.map((task) => task.modelType)]; + return Array.from(new Set(values.filter((value): value is string => Boolean(value)))).sort((a, b) => a.localeCompare(b)); + }, [taskQuery.modelType, tasks]); + const pageSizeOptions = useMemo(() => { + return Array.from(new Set([...taskPageSizeOptions, taskQuery.pageSize])).sort((a, b) => a - b); + }, [taskQuery.pageSize]); + const totalPages = Math.max(1, Math.ceil(props.total / taskQuery.pageSize)); + const currentPage = Math.min(taskQuery.page, totalPages); + const pageStart = props.total ? Math.min((currentPage - 1) * taskQuery.pageSize + 1, props.total) : 0; + const pageEnd = Math.min(currentPage * taskQuery.pageSize, props.total); + const hasActiveFilters = Boolean(taskQuery.query || taskQuery.createdFrom || taskQuery.createdTo || taskQuery.modelType); + + useEffect(() => { + if (taskQuery.page > totalPages) { + props.onQueryChange({ ...taskQuery, page: totalPages }); + } + }, [props.onQueryChange, taskQuery, totalPages]); + + useEffect(() => { + setPageJump(String(currentPage)); + }, [currentPage]); + + async function copyTaskRequestId(task: GatewayTask) { + if (!task.requestId) return; + await navigator.clipboard.writeText(task.requestId); + setLocalMessage(`已复制 RequestID:${task.requestId}`); + } + + function resetFilters() { + props.onQueryChange({ + query: '', + modelType: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: taskQuery.pageSize, + }); + } + + function updateQuery(value: string) { + props.onQueryChange({ ...taskQuery, query: value, page: 1 }); + } + + function updateTypeFilter(value: string) { + props.onQueryChange({ ...taskQuery, modelType: value === 'all' ? '' : value, page: 1 }); + } + + function updateCreatedRange(value: { from: string; to: string }) { + props.onQueryChange({ ...taskQuery, createdFrom: value.from, createdTo: value.to, page: 1 }); + } + + function updatePageSize(value: string) { + const nextPageSize = Number(value); + props.onQueryChange({ ...taskQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 }); + } + + function updatePage(page: number) { + props.onQueryChange({ ...taskQuery, page }); + } + + function submitPageJump() { + const parsed = Number(pageJump); + if (!Number.isFinite(parsed) || parsed <= 0) { + setPageJump(String(currentPage)); + return; + } + updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed)))); + } + return ( - - - 任务记录 - - - {tasks.length ? ( -
- {tasks.map((task) => ( - - ))} -
- ) : ( -
- 暂无任务 -
- )} -
-
+ <> + + + {localMessage &&

{localMessage}

} + + + + + + + + + {tasks.length ? ( + + + 任务 + RequestID + 状态 + 模型 + 类型 + API Key + Token + 扣费 + 耗时 + 创建时间 + 原始 JSON + + {tasks.map((task) => ( + + ))} +
+ ) : ( +
+ {hasActiveFilters ? '没有匹配的任务' : '暂无任务'} + {hasActiveFilters && 调整关键词、类型或创建时间后再试。} +
+ )} + + +
+ + 共 {props.total} 条 · {pageStart}-{pageEnd} +
+
{ + event.preventDefault(); + submitPageJump(); + }} + > + 第 {currentPage} / {totalPages} 页 + 跳至 + setPageJump(event.target.value)} + /> + + +
+ + + + +
+
+
+
+ + setJsonTask(null)}>关闭} + open={Boolean(jsonTask)} + title="任务原始 JSON" + onClose={() => setJsonTask(null)} + onSubmit={(event) => event.preventDefault()} + > +
{JSON.stringify(jsonTask, null, 2)}
+
+ ); } -function TaskRecord(props: { task: GatewayTask }) { +function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayTask) => Promise; onOpenJson: (task: GatewayTask) => void }) { const usage = props.task.usage ?? {}; - const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-'; - const chargeText = props.task.finalChargeAmount !== undefined ? `${props.task.finalChargeAmount}` : '-'; + const tokenUsage = formatTokenUsage(usage); + const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-'; + const resolvedModel = props.task.resolvedModel || props.task.model; const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary'; return ( -
-
- {props.task.status} - {props.task.kind} - {props.task.model} - {formatDateTime(props.task.createdAt)} -
-
- - - - - - - - -
-
{JSON.stringify({ result: props.task.result, usage: props.task.usage, billings: props.task.billings, billingSummary: props.task.billingSummary, metrics: props.task.metrics }, null, 2)}
-
+ + + + {props.task.kind} + + ID + {props.task.id} + + + + + + {props.task.requestId || '-'} + + + + {props.task.status} + + + {resolvedModel} + {props.task.requestedModel && props.task.requestedModel !== resolvedModel && {props.task.requestedModel}} + + + {props.task.modelType || '-'} + {props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'} + {tokenUsage} + {chargeText} + {formatDuration(props.task.responseDurationMs)} + {formatDateTime(props.task.createdAt)} + + + + ); } +function formatCellValue(value: unknown) { + if (value === undefined || value === null || value === '') return '-'; + return String(value); +} + +function formatTokenUsage(usage: Record) { + const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens); + const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens); + const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null)); + if (input === null && output === null && total === null) return '-'; + return ( + + 输入:{formatCellValue(input)}/输出:{formatCellValue(output)} + 总计:{formatCellValue(total)} + + ); +} + +function tokenValue(value: unknown) { + if (value === undefined || value === null || value === '') return null; + const numericValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numericValue) ? numericValue : null; +} + +function formatDuration(value?: number) { + if (value === undefined || value === null) return '-'; + const milliseconds = Math.max(0, Math.round(value)); + if (milliseconds === 0) return '0秒'; + if (milliseconds < 1000) return `${milliseconds}毫秒`; + const totalSeconds = Math.round(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`; + if (minutes > 0) return `${minutes}分${seconds}秒`; + return `${seconds}秒`; +} + +function taskErrorText(task: GatewayTask) { + return task.errorCode || task.errorMessage || task.error || ''; +} + function InfoItem(props: { label: string; value: string }) { return (
diff --git a/apps/web/src/pages/admin/PlatformManagementPanel.tsx b/apps/web/src/pages/admin/PlatformManagementPanel.tsx index cb83b95..185f3cb 100644 --- a/apps/web/src/pages/admin/PlatformManagementPanel.tsx +++ b/apps/web/src/pages/admin/PlatformManagementPanel.tsx @@ -58,6 +58,7 @@ export function PlatformManagementPanel(props: { const text = [ model.displayName, model.modelName, + model.providerModelName, model.modelAlias, ...model.modelType, model.provider, @@ -482,9 +483,9 @@ function PlatformModelTable(props: { meta={[ platform ? platformDisplayName(platform) : model.platformName ?? '-', provider?.displayName ?? platform?.provider ?? model.provider ?? '-', - model.modelAlias || model.baseModelId || '-', + model.modelAlias || model.modelName || '-', ]} - subtitle={model.modelName} + subtitle={model.providerModelName ? `调用模型名:${model.providerModelName}` : model.modelName} title={model.displayName || model.modelName} /> ); @@ -608,10 +609,13 @@ function ModelSelection(props: { const next = new Set(selectedIds); next.delete(modelId); const modelDiscountFactors = { ...props.form.modelDiscountFactors }; + const modelNameMappings = { ...props.form.modelNameMappings }; delete modelDiscountFactors[modelId]; + delete modelNameMappings[modelId]; props.onChange({ ...props.form, modelDiscountFactors, + modelNameMappings, selectionMode: 'partial', selectedModelIds: Array.from(next), }); @@ -633,6 +637,16 @@ function ModelSelection(props: { }); } + function updateModelNameMapping(modelId: string, value: string) { + props.onChange({ + ...props.form, + modelNameMappings: { + ...props.form.modelNameMappings, + [modelId]: value, + }, + }); + } + return (
@@ -649,26 +663,44 @@ function ModelSelection(props: {
当前没有已选模型,点击“添加模型”从模型库选择。
) : (
- {selectedModels.map((model) => ( -
-
- - {stableModelAlias(model) || model.providerModelName} - {props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)} - + {selectedModels.map((model) => { + const modelLabel = stableModelAlias(model) || model.providerModelName; + const providerModelName = props.form.modelNameMappings[model.id] ?? model.providerModelName; + return ( +
+
+ + {modelLabel} + {props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)} + +
+
+ + +
+
- updateModelDiscount(model.id, event.target.value)} - /> - -
- ))} + ); + })}
)} >((acc, model) => { + const baseModel = findBaseModelForPlatformModel(platform, baseModels, model); + if (baseModel?.id) acc[baseModel.id] = model.providerModelName || model.modelName; + return acc; + }, {}); +} + function findBaseModelForPlatformModel(platform: IntegrationPlatform | undefined, baseModels: BaseModelCatalogItem[], model: PlatformModel) { return baseModels.find((item) => item.id === model.baseModelId) ?? baseModels.find((item) => item.canonicalModelKey === model.modelAlias) ?? baseModels.find((item) => stableModelAlias(item) === model.modelAlias) ?? + baseModels.find((item) => item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type))) ?? baseModels.find((item) => item.providerKey === platform?.provider && item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type))); } diff --git a/apps/web/src/pages/admin/platform-form.ts b/apps/web/src/pages/admin/platform-form.ts index 9fea8dc..486b585 100644 --- a/apps/web/src/pages/admin/platform-form.ts +++ b/apps/web/src/pages/admin/platform-form.ts @@ -34,6 +34,7 @@ export interface PlatformWizardForm { supportUrlInput: boolean; modelDiscountFactor: string; modelDiscountFactors: Record; + modelNameMappings: Record; modelOverrideRetry: boolean; modelRetryEnabled: boolean; modelRetryMaxAttempts: string; @@ -78,6 +79,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec supportUrlInput: true, modelDiscountFactor: '', modelDiscountFactors: {}, + modelNameMappings: {}, modelOverrideRetry: false, modelRetryEnabled: true, modelRetryMaxAttempts: '2', @@ -99,6 +101,7 @@ export function applyProviderDefaults(form: PlatformWizardForm, provider: string authType: defaults?.defaultAuthType ?? 'APIKey', selectedModelIds: [], modelDiscountFactors: {}, + modelNameMappings: {}, }; } @@ -148,6 +151,7 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat baseModelId: model.id, canonicalModelKey: model.canonicalModelKey, modelName: model.providerModelName, + providerModelName: optionalString(form.modelNameMappings[model.id]) ?? model.providerModelName, modelAlias: stableModelAlias(model), modelType: baseModelTypes(model), displayName: stableModelAlias(model) || model.providerModelName, @@ -329,8 +333,8 @@ function limitRule(metric: string, value: string, windowSeconds = 60) { }; } -function optionalString(value: string) { - const trimmed = value.trim(); +function optionalString(value: string | null | undefined) { + const trimmed = value?.trim() ?? ''; return trimmed || undefined; } diff --git a/apps/web/src/routing.ts b/apps/web/src/routing.ts index 0103f9f..582ac7f 100644 --- a/apps/web/src/routing.ts +++ b/apps/web/src/routing.ts @@ -1,4 +1,4 @@ -import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection } from './types'; +import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection, WorkspaceTaskQuery } from './types'; export interface AppRouteState { activePage: PageKey; @@ -6,6 +6,7 @@ export interface AppRouteState { apiDocSection: ApiDocSection; playgroundMode: PlaygroundMode; workspaceSection: WorkspaceSection; + workspaceTaskQuery: WorkspaceTaskQuery; } export const defaultRouteState: AppRouteState = { @@ -14,6 +15,7 @@ export const defaultRouteState: AppRouteState = { apiDocSection: 'chat', playgroundMode: 'chat', workspaceSection: 'overview', + workspaceTaskQuery: defaultWorkspaceTaskQuery(), }; const workspacePaths: Record = { @@ -55,21 +57,23 @@ const adminSections = reverseMap(adminPaths); const docsSections = reverseMap(docsPaths); const playgroundSections = reverseMap(playgroundPaths); -export function parseAppRoute(pathname = window.location.pathname): AppRouteState { - const path = normalizePath(pathname); +export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState { + const url = new URL(input, window.location.origin); + const path = normalizePath(url.pathname); + const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams); if (path === '/') return { ...defaultRouteState }; if (path.startsWith('/playground')) { - return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path) }; + return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery }; } - if (path === '/models') return { ...defaultRouteState, activePage: 'models' }; + if (path === '/models') return { ...defaultRouteState, activePage: 'models', workspaceTaskQuery }; if (path.startsWith('/workspace')) { - return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path) }; + return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path), workspaceTaskQuery }; } if (path.startsWith('/admin')) { - return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path) }; + return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path), workspaceTaskQuery }; } if (path.startsWith('/docs')) { - return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path) }; + return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path), workspaceTaskQuery }; } return { ...defaultRouteState }; } @@ -87,6 +91,19 @@ export function pathForWorkspaceSection(section: WorkspaceSection) { return workspacePaths[section] ?? workspacePaths.overview; } +export function pathForWorkspaceTaskQuery(query: WorkspaceTaskQuery) { + const normalized = normalizeWorkspaceTaskQuery(query); + const search = new URLSearchParams(); + if (normalized.query) search.set('q', normalized.query); + if (normalized.modelType) search.set('type', normalized.modelType); + if (normalized.createdFrom) search.set('from', normalized.createdFrom); + if (normalized.createdTo) search.set('to', normalized.createdTo); + if (normalized.page !== 1) search.set('page', String(normalized.page)); + if (normalized.pageSize !== 10) search.set('pageSize', String(normalized.pageSize)); + const suffix = search.toString(); + return `${workspacePaths.tasks}${suffix ? `?${suffix}` : ''}`; +} + export function pathForAdminSection(section: AdminSection) { return adminPaths[section] ?? adminPaths.overview; } @@ -131,3 +148,50 @@ function normalizePath(pathname: string) { function reverseMap(value: Record) { return Object.fromEntries(Object.entries(value).map(([key, path]) => [path, key])) as Record; } + +export function defaultWorkspaceTaskQuery(): WorkspaceTaskQuery { + return { + query: '', + modelType: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: 10, + }; +} + +export function normalizeWorkspaceTaskQuery(query: WorkspaceTaskQuery): WorkspaceTaskQuery { + return { + query: query.query.trim(), + modelType: query.modelType.trim(), + createdFrom: query.createdFrom.trim(), + createdTo: query.createdTo.trim(), + page: positiveInt(query.page, 1), + pageSize: clampPageSize(query.pageSize), + }; +} + +export function workspaceTaskQueryKey(query: WorkspaceTaskQuery) { + const normalized = normalizeWorkspaceTaskQuery(query); + return JSON.stringify(normalized); +} + +function parseWorkspaceTaskQuery(search: URLSearchParams): WorkspaceTaskQuery { + return normalizeWorkspaceTaskQuery({ + query: search.get('q') ?? search.get('query') ?? '', + modelType: search.get('type') ?? search.get('modelType') ?? '', + createdFrom: search.get('from') ?? search.get('createdFrom') ?? '', + createdTo: search.get('to') ?? search.get('createdTo') ?? '', + page: Number(search.get('page') ?? 1), + pageSize: Number(search.get('pageSize') ?? search.get('limit') ?? 10), + }); +} + +function positiveInt(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +function clampPageSize(value: number) { + const normalized = positiveInt(value, 10); + return Math.min(100, Math.max(1, normalized)); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4fb1808..0c60743 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -34,14 +34,14 @@ --color-text-strong: var(--text-strong); --color-text-normal: var(--text-normal); --color-text-soft: var(--text-soft); - --text-xs: 12px; - --text-sm: 13px; - --text-base: 14px; - --text-md: 15px; - --text-lg: 16px; - --text-xl: 18px; - --text-2xl: 22px; - --text-3xl: 30px; + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 0.875rem; + --text-md: 0.9375rem; + --text-lg: 1rem; + --text-xl: 1.125rem; + --text-2xl: 1.375rem; + --text-3xl: 1.875rem; --radius-sm: 7px; --radius-md: 8px; --radius-lg: 10px; @@ -144,7 +144,7 @@ p { h1 { color: var(--text-strong); - font-size: 28px; + font-size: 1.75rem; font-weight: var(--font-weight-semibold); line-height: 1.15; } @@ -163,7 +163,7 @@ strong { .eyebrow { margin-bottom: 5px; color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; } @@ -224,21 +224,162 @@ strong { gap: 14px; } -.taskList { +.taskRecordFilters { + grid-template-columns: minmax(240px, 1.4fr) minmax(150px, 0.65fr) minmax(330px, 1.2fr) auto; + gap: 10px; +} + +.taskRecordRangeLabel { + min-width: 0; +} + +.taskRecordViewport { + --sh-table-viewport-gap: 10px; + --sh-table-viewport-height: calc(100dvh - 90px); + margin-bottom: -52px; +} + +.taskRecordSearchBox { display: grid; - gap: 14px; -} - -.taskRecordHeader { - display: flex; - flex-wrap: wrap; + grid-template-columns: 18px minmax(0, 1fr); align-items: center; - gap: 8px 12px; - color: var(--muted-foreground); + gap: 8px; + min-height: 32px; + padding-left: 10px; + border: 1px solid var(--input); + border-radius: var(--control-radius); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); } -.taskRecordHeader strong { +.taskRecordSearchBox svg { + color: var(--text-soft); +} + +.taskRecordSearchBox .shInput { + border: 0; + box-shadow: none; +} + +.taskRecordTable .shTableRow { + grid-template-columns: minmax(190px, 0.95fr) minmax(220px, 1.05fr) minmax(94px, 0.42fr) minmax(280px, 1.55fr) minmax(126px, 0.58fr) minmax(150px, 0.7fr) minmax(154px, 0.66fr) minmax(82px, 0.38fr) minmax(98px, 0.45fr) minmax(150px, 0.7fr) minmax(130px, 0.58fr); + min-width: 1674px; + align-items: start; +} + +.taskRecordPrimaryCell { + display: grid; + min-width: 0; + gap: 3px; +} + +.taskRecordPrimaryCell strong, +.taskRecordPrimaryCell small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskRecordPrimaryCell strong { color: var(--text-strong); + font-weight: var(--font-weight-semibold); +} + +.taskRecordPrimaryCell small { + color: var(--text-soft); + font-size: var(--font-size-xs); +} + +.taskRecordIdentityCell { + gap: 6px; +} + +.taskRecordIdLine, +.taskRecordRequestLine { + display: grid; + min-width: 0; + align-items: center; + gap: 6px; +} + +.taskRecordIdLine { + grid-template-columns: 28px minmax(0, 1fr); +} + +.taskRecordRequestLine { + grid-template-columns: minmax(0, 1fr) 28px; +} + +.taskRecordIdLine > span { + color: var(--text-soft); + font-size: var(--font-size-xs); +} + +.taskRecordIdLine code, +.taskRecordRequestLine code { + overflow: hidden; + min-width: 0; + color: var(--text-normal); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskRecordRequestLine .shButton { + width: 28px; + min-height: 28px; +} + +.taskRecordTokenCell { + overflow: visible; + white-space: normal; +} + +.taskRecordTokenUsage { + display: grid; + gap: 2px; + line-height: 1.3; +} + +.taskRecordModelCell { + overflow: visible; + white-space: normal; +} + +.taskRecordModelCell .taskRecordPrimaryCell strong, +.taskRecordModelCell .taskRecordPrimaryCell small { + overflow: visible; + text-overflow: clip; + white-space: normal; + word-break: break-word; +} + +.taskRecordJsonButton { + width: 100%; + justify-content: flex-start; +} + +.taskJsonDialog { + width: min(920px, 100%); +} + +.taskJsonDialogBody { + grid-template-columns: minmax(0, 1fr); + padding: 0; +} + +.taskJsonPreview { + overflow: auto; + max-height: min(620px, calc(100vh - 188px)); + margin: 0; + padding: 16px 18px; + background: var(--surface-subtle); + color: var(--text-normal); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; } .appShell { @@ -465,7 +606,7 @@ strong { display: block; margin-top: 10px; color: var(--text-strong); - font-size: 24px; + font-size: 1.5rem; font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight); } @@ -526,4 +667,17 @@ strong { .tokenInline input { width: 100%; } + + .taskRecordFilters { + grid-template-columns: 1fr; + } + + .shTableFooter { + align-items: flex-start; + flex-direction: column; + } + + .shTablePageActions { + flex-wrap: wrap; + } } diff --git a/apps/web/src/styles/api-docs.css b/apps/web/src/styles/api-docs.css index 941a77c..c6acc32 100644 --- a/apps/web/src/styles/api-docs.css +++ b/apps/web/src/styles/api-docs.css @@ -30,7 +30,7 @@ .docsBrand { margin-bottom: 18px; - font-size: 17px; + font-size: 1.0625rem; } .docsSearch { @@ -56,7 +56,7 @@ .docsGroup h3 { margin-bottom: 4px; color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; } .docsGroup button { @@ -129,7 +129,7 @@ padding: 13px 16px; border-bottom: 1px solid #f0f2f5; color: var(--text-soft); - font-size: 13px; + font-size: 0.8125rem; } .paramRow em { @@ -162,7 +162,7 @@ padding: 12px; border-radius: 8px; background: #f7f8fa; - font-size: 12px; + font-size: 0.75rem; } @media (max-width: 1180px) { diff --git a/apps/web/src/styles/landing.css b/apps/web/src/styles/landing.css index 9acbae4..d556afb 100644 --- a/apps/web/src/styles/landing.css +++ b/apps/web/src/styles/landing.css @@ -21,7 +21,7 @@ .releaseNotice span { color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; } .landingHero { @@ -43,14 +43,14 @@ .landingCopy h1 { max-width: 720px; - font-size: 48px; + font-size: 3rem; line-height: 1.08; } .landingCopy p { max-width: 660px; color: var(--text-soft); - font-size: 16px; + font-size: 1rem; line-height: 1.75; } @@ -102,12 +102,12 @@ .previewGrid span { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-semibold); } .previewGrid strong { - font-size: 15px; + font-size: 0.9375rem; } .previewFlow { @@ -115,7 +115,7 @@ border-radius: 10px; background: var(--primary); color: #fff; - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-semibold); } @@ -134,7 +134,7 @@ } .landingSectionHeader h2 { - font-size: 22px; + font-size: 1.375rem; } .coverageGrid, @@ -161,18 +161,18 @@ .landingFeatureCard span, .advantageCard p { color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; line-height: 1.6; } .landingFeatureCard strong { display: block; margin: 8px 0; - font-size: 18px; + font-size: 1.125rem; } .advantageCard strong { - font-size: 17px; + font-size: 1.0625rem; } .loginRequiredPage { @@ -208,7 +208,7 @@ } .landingCopy h1 { - font-size: 34px; + font-size: 2.125rem; } .coverageGrid, diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index 3aeaf42..969b340 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -47,7 +47,7 @@ .filterGroup h3 { color: #1f2937; - font-size: 14px; + font-size: 0.875rem; } .filterChips { @@ -57,22 +57,61 @@ } .filterChip { + display: inline-flex; + align-items: center; + gap: 6px; min-height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: #fff; color: var(--text-normal); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-semibold); } +.filterChip em { + color: var(--text-soft); + font-size: 0.6875rem; + font-style: normal; + font-weight: var(--font-weight-semibold); +} + +.filterChipIcon { + display: grid; + width: 18px; + height: 18px; + flex: 0 0 18px; + place-items: center; + overflow: hidden; + border-radius: 50%; + background: var(--surface-subtle); + color: var(--text-soft); + font-size: 0.5625rem; + line-height: 1; +} + +.filterChipIcon img { + width: 100%; + height: 100%; + object-fit: contain; +} + .filterChip[data-active="true"] { border-color: var(--primary); background: var(--primary); color: #fff; } +.filterChip[data-active="true"] em { + color: rgba(255, 255, 255, 0.76); +} + +.filterChip[data-active="true"] .filterChipIcon { + background: rgba(255, 255, 255, 0.18); + color: #fff; +} + .modelsContent { display: grid; min-width: 0; @@ -143,65 +182,173 @@ .modelCards { display: grid; grid-template-columns: repeat(3, minmax(260px, 1fr)); - gap: 14px; + gap: 10px; } .modelCard .shCardContent { display: grid; - min-height: 150px; - gap: 13px; + align-content: start; + min-height: 265px; + gap: 10px; + padding: 14px; } .modelCardTop { display: grid; - grid-template-columns: 42px minmax(0, 1fr) auto; - gap: 11px; + grid-template-columns: 38px minmax(0, 1fr); + gap: 10px; align-items: start; } -.modelCardTop strong, -.modelCardTop span, -.modelCard p { +.modelCardTop .modelIcon { + width: 38px; + height: 38px; +} + +.modelCardTop .modelIconImage { + padding: 6px; +} + +.modelCardHeaderText { + display: grid; + min-width: 0; + gap: 4px; +} + +.modelCardTop strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.modelCardTop span, -.modelCard p, -.modelCardFooter span { - color: var(--muted-foreground); - font-size: 12px; +.modelCardIntro { + display: grid; + align-content: start; + gap: 7px; +} + +.modelCardDescription { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--text-soft); + line-height: 1.48; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .modelTags { display: flex; flex-wrap: wrap; - gap: 6px; + align-content: flex-start; + align-items: flex-start; + gap: 5px; } .modelTags span { - padding: 4px 7px; - border: 1px solid #eceff3; - border-radius: 999px; - background: var(--surface-subtle); - color: var(--text-soft); - font-size: 12px; -} - -.modelCardFooter { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: auto; -} - -.modelCardFooter a { - color: var(--text-strong); - font-size: 12px; + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid rgba(37, 99, 235, 0.32); + border-radius: 6px; + background: rgba(37, 99, 235, 0.06); + color: #1d4ed8; font-weight: var(--font-weight-semibold); - text-decoration: none; +} + +.modelTags span:nth-child(4n + 2) { + border-color: rgba(5, 150, 105, 0.32); + background: rgba(5, 150, 105, 0.07); + color: #047857; +} + +.modelTags span:nth-child(4n + 3) { + border-color: rgba(124, 58, 237, 0.32); + background: rgba(124, 58, 237, 0.07); + color: #6d28d9; +} + +.modelTags span:nth-child(4n + 4) { + border-color: rgba(217, 119, 6, 0.32); + background: rgba(217, 119, 6, 0.07); + color: #b45309; +} + +.modelCardFacts { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(0, 0.55fr); + gap: 7px 10px; + margin: 0; + padding: 0.625rem; + border-radius: 0.5rem; + background: var(--surface-subtle); +} + +.modelCardFacts div { + min-width: 0; +} + +.modelCardFacts dt { + margin-bottom: 2px; + color: var(--muted-foreground); +} + +.modelCardFacts dd { + margin: 0; + color: var(--text-normal); + font-weight: var(--font-weight-semibold); + line-height: 1.35; + word-break: break-word; +} + +.modelCardFactRateLimit dd { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: normal; +} + +.modelCardFacts .modelCardFactFull { + grid-column: 1 / -1; +} + +.modelPermissionTags { + display: flex; + flex-wrap: wrap; + gap: 0.1875rem; +} + +.modelPermissionTag { + display: inline-flex; + align-items: center; + max-width: 100%; + gap: 0.1875rem; + padding: 0.0625rem 0.3125rem; + border-radius: 999rem; + line-height: 1.2; +} + +.modelPermissionTag span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelPermissionTagAllow { + background: rgba(5, 150, 105, 0.1); + color: #047857; +} + +.modelPermissionTagDeny { + background: rgba(220, 38, 38, 0.1); + color: #b91c1c; +} + +.modelPermissionIcon { + width: 0.625rem; + height: 0.625rem; + flex: 0 0 auto; + stroke-width: 2.5; } .providerToolbar { @@ -214,7 +361,7 @@ .providerToolbar p { margin: 0; color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; } .inlineActions { @@ -511,7 +658,7 @@ .formMessage { margin-top: 12px; color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; font-weight: var(--font-weight-medium); } @@ -569,7 +716,7 @@ .providerCatalogBody span, .providerCatalogMeta { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .providerCatalogMeta, @@ -625,7 +772,7 @@ .baseModelCardBody > div:first-child span { margin-top: 4px; color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .baseModelCard .providerCatalogActions { @@ -644,7 +791,7 @@ border-radius: 999px; background: var(--surface-subtle); color: #4b5563; - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-semibold); } @@ -680,7 +827,7 @@ .baseModelForm textarea { min-height: 124px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; + font-size: 0.75rem; } .platformGrid { @@ -716,7 +863,7 @@ .platformCardBody > div:first-child span, .platformCardBody p { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .platformCardBody p { @@ -833,7 +980,7 @@ .platformEmptyState strong { color: var(--text-strong); - font-size: 18px; + font-size: 1.125rem; } .platformEmptyState span { @@ -872,7 +1019,7 @@ .platformModelRow span { overflow: hidden; color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; text-overflow: ellipsis; white-space: nowrap; } @@ -942,12 +1089,12 @@ .platformToggle strong { color: var(--text-normal); - font-size: 13px; + font-size: 0.8125rem; } .platformToggle small { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .platformReadonlyField { @@ -955,7 +1102,7 @@ place-items: center start; padding: 0 11px; color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; font-weight: var(--font-weight-semibold); } @@ -975,7 +1122,7 @@ overflow: hidden; color: var(--text-normal); font-family: var(--font-sans); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-medium); text-overflow: ellipsis; white-space: nowrap; @@ -983,7 +1130,7 @@ .platformCredentialField small { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-normal); line-height: 1.4; } @@ -1022,12 +1169,12 @@ .platformModelSelectorHeader strong { color: var(--text-strong); - font-size: 14px; + font-size: 0.875rem; } .platformModelSelectorHeader span { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .platformSegmented { @@ -1059,8 +1206,8 @@ .platformModelChoices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - max-height: 310px; + gap: 0.625rem; + max-height: 19.375rem; overflow: auto; } @@ -1070,39 +1217,54 @@ .platformModelChoice { display: grid; - grid-template-columns: minmax(0, 1fr) 112px auto; - gap: 10px; + grid-template-columns: minmax(10rem, 1fr) minmax(14rem, 1.4fr) auto; + gap: 0.625rem; align-items: center; - padding: 12px; + padding: 0.75rem; border: 1px solid var(--border-subtle); - border-radius: 8px; + border-radius: 0.5rem; background: #fff; } .platformModelChoiceMain { display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 10px; + gap: 0.625rem; align-items: start; min-width: 0; } .platformModelChoice input { - margin-top: 3px; + margin-top: 0.1875rem; accent-color: var(--text-strong); } -.platformModelChoice > .shInput { +.platformModelChoiceFields { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5rem; + gap: 0.5rem; + align-items: end; + min-width: 0; +} + +.platformModelChoiceFields label { + gap: 0.25rem; + color: var(--muted-foreground); + font-size: 0.75rem; + min-width: 0; +} + +.platformModelChoiceFields .shInput { margin-top: 0; - min-height: 34px; - padding: 0 9px; - font-size: 12px; + min-height: 2rem; + padding: 0 0.5625rem; + font-size: 0.75rem; } .platformModelChoiceMain span { display: grid; min-width: 0; - gap: 4px; + gap: 0.25rem; } .platformModelChoiceMain strong, @@ -1115,7 +1277,7 @@ .platformModelChoiceMain small, .platformModelEmpty { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .platformModelEmpty { @@ -1164,12 +1326,12 @@ .modelPickerHeader strong { color: var(--text-strong); - font-size: 15px; + font-size: 0.9375rem; } .modelPickerHeader span { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .modelPickerToolbar { @@ -1242,12 +1404,12 @@ .modelPickerItem strong { color: var(--text-strong); - font-size: 13px; + font-size: 0.8125rem; } .modelPickerItem small { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .modelPickerActions { @@ -1295,7 +1457,7 @@ .runtimePolicyCard header span, .runtimePolicyCard p { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .runtimePolicySummary { @@ -1312,7 +1474,7 @@ border-radius: 8px; background: var(--surface-subtle); color: var(--text-normal); - font-size: 12px; + font-size: 0.75rem; white-space: nowrap; } @@ -1348,7 +1510,7 @@ .runtimePolicySection header span { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .runtimePolicyRows { @@ -1401,6 +1563,14 @@ grid-template-columns: 1fr; } + .platformModelChoice { + grid-template-columns: 1fr auto; + } + + .platformModelChoiceFields { + grid-column: 1 / -1; + } + .providerCatalogCard { grid-template-columns: 48px minmax(0, 1fr); } diff --git a/apps/web/src/styles/playground.css b/apps/web/src/styles/playground.css index b49f5e4..557e07c 100644 --- a/apps/web/src/styles/playground.css +++ b/apps/web/src/styles/playground.css @@ -1166,7 +1166,7 @@ .mediaTaskTimeline h1 { width: min(1240px, 100%); - font-size: 30px; + font-size: 1.875rem; } .mediaTaskTimeline > .playgroundError { diff --git a/apps/web/src/styles/pricing.css b/apps/web/src/styles/pricing.css index e719966..ec355f1 100644 --- a/apps/web/src/styles/pricing.css +++ b/apps/web/src/styles/pricing.css @@ -29,7 +29,7 @@ .pricingRuleCard p { margin: 0; color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .pricingRuleItems { @@ -47,7 +47,7 @@ border: 1px solid var(--border-subtle); background: var(--surface-subtle); color: #4b5563; - font-size: 12px; + font-size: 0.75rem; } .pricingRuleItems strong { @@ -55,7 +55,7 @@ align-items: center; gap: 6px; color: var(--text-strong); - font-size: 13px; + font-size: 0.8125rem; } .pricingRuleItems svg { @@ -96,13 +96,13 @@ .pricingModeRule strong, .pricingWeightTable strong { display: block; - font-size: 14px; + font-size: 0.875rem; } .pricingModeHeader span, .pricingModeRule header span { color: var(--muted-foreground); - font-size: 12px; + font-size: 0.75rem; } .pricingModeTabs { @@ -119,7 +119,7 @@ border-bottom: 3px solid transparent; background: transparent; color: var(--text-soft); - font-size: 14px; + font-size: 0.875rem; font-weight: var(--font-weight-semibold); } @@ -313,7 +313,7 @@ border-right: 1px solid var(--border); background: var(--surface-subtle); color: var(--text-normal); - font-size: 12px; + font-size: 0.75rem; font-weight: var(--font-weight-medium); line-height: 1.35; } @@ -332,7 +332,7 @@ align-items: center; padding: 0 12px; color: var(--text-strong); - font-size: 13px; + font-size: 0.8125rem; } .pricingFormulaBox { @@ -348,7 +348,7 @@ .pricingFormulaBox p { margin-top: 8px; color: var(--muted-foreground); - font-size: 13px; + font-size: 0.8125rem; font-weight: var(--font-weight-medium); } @@ -397,7 +397,7 @@ border-radius: 8px; background: var(--surface-subtle); color: var(--text-normal); - font-size: 13px; + font-size: 0.8125rem; font-weight: var(--font-weight-medium); } diff --git a/apps/web/src/styles/ui.css b/apps/web/src/styles/ui.css index ecca84b..3cdc5cc 100644 --- a/apps/web/src/styles/ui.css +++ b/apps/web/src/styles/ui.css @@ -671,10 +671,250 @@ background: var(--surface); } +.shDateRangePicker.ant-picker { + min-height: 36px; + padding: 0 10px; + border-color: var(--input); + border-radius: var(--control-radius); + background: var(--surface); + color: var(--text-normal); + font-family: inherit; + font-size: var(--font-size-base); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04); + transition: border-color 0.16s ease, box-shadow 0.16s ease; +} + +.shDateRangePicker.ant-picker:hover, +.shDateRangePicker.ant-picker-focused { + border-color: var(--ring); + box-shadow: 0 0 0 1px var(--ring); +} + +.shDateRangePicker.ant-picker-disabled { + background: var(--surface-muted); + opacity: 0.64; +} + +.shDateRangePicker .ant-picker-input > input { + color: var(--text-normal); + font-family: inherit; + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); +} + +.shDateRangePicker .ant-picker-input > input::placeholder { + color: var(--muted-foreground); + font-weight: var(--font-weight-regular); +} + +.shDateRangePicker .ant-picker-active-bar { + background: var(--primary); +} + +.shDateRangePicker .ant-picker-range-separator, +.shDateRangePicker .ant-picker-suffix, +.shDateRangePicker .ant-picker-clear { + color: var(--text-soft); +} + +.shDateRangePicker .ant-picker-clear { + background: var(--surface); +} + +.shDateRangePickerPopup { + font-family: inherit; +} + +.shDateRangePickerPopup .ant-picker-panel-container { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--popover); + color: var(--popover-foreground); + box-shadow: var(--shadow-dialog); +} + +.shDateRangePickerPopup .ant-picker-panel, +.shDateRangePickerPopup .ant-picker-date-panel, +.shDateRangePickerPopup .ant-picker-time-panel, +.shDateRangePickerPopup .ant-picker-footer, +.shDateRangePickerPopup .ant-picker-content th, +.shDateRangePickerPopup .ant-picker-header { + border-color: var(--border-subtle); +} + +.shDateRangePickerPopup .ant-picker-header, +.shDateRangePickerPopup .ant-picker-content th, +.shDateRangePickerPopup .ant-picker-cell, +.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell .ant-picker-time-panel-cell-inner { + color: var(--text-soft); + font-family: inherit; + font-size: var(--font-size-sm); +} + +.shDateRangePickerPopup .ant-picker-header-view, +.shDateRangePickerPopup .ant-picker-header button, +.shDateRangePickerPopup .ant-picker-cell-in-view, +.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell-selected .ant-picker-time-panel-cell-inner { + color: var(--text-strong); +} + +.shDateRangePickerPopup .ant-picker-cell-inner { + border-radius: var(--control-radius); +} + +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-today .ant-picker-cell-inner::before { + border-color: var(--ring); + border-radius: var(--control-radius); +} + +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-start .ant-picker-cell-inner, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-end .ant-picker-cell-inner { + background: var(--primary); + color: var(--primary-foreground); +} + +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-in-range::before, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-start:not(.ant-picker-cell-range-start-single)::before, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-end:not(.ant-picker-cell-range-end-single)::before { + background: var(--surface-muted); +} + +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover::before, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover-start::before, +.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover-end::before { + background: var(--accent); +} + +.shDateRangePickerPopup .ant-picker-cell:hover .ant-picker-cell-inner, +.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell .ant-picker-time-panel-cell-inner:hover { + background: var(--accent); +} + +.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell-selected .ant-picker-time-panel-cell-inner { + background: var(--surface-muted); + font-weight: var(--font-weight-semibold); +} + +.shDateRangePickerPopup .ant-picker-ok .ant-btn-primary { + border-color: var(--primary); + border-radius: var(--control-radius); + background: var(--primary); + color: var(--primary-foreground); + box-shadow: none; +} + +.shDateRangePickerPopup .ant-picker-now-btn { + color: var(--text-normal); +} + .shTable { overflow: auto; } +.shTableViewportCard { + display: grid; + max-height: var(--sh-table-viewport-height, calc(100dvh - 142px)); + min-height: 0; +} + +.shTableViewportPanel { + display: flex; + min-height: 0; + max-height: var(--sh-table-viewport-height, calc(100dvh - 142px)); + flex-direction: column; + gap: var(--sh-table-viewport-gap, 14px); + overflow: hidden; +} + +.shTableViewportPanel > .formMessage { + margin: 0; +} + +.shTableViewportLayout { + display: flex; + min-height: 0; + max-height: 100%; + flex: 0 1 auto; + flex-direction: column; + gap: var(--sh-table-viewport-gap, 14px); + overflow: hidden; +} + +.shTableToolbar { + display: grid; + flex: 0 0 auto; + align-items: end; + gap: 12px; +} + +.shTableSummary, +.shTableFooter, +.shTablePageActions { + display: flex; + align-items: center; + gap: 10px; +} + +.shTableFooter { + flex: 0 0 auto; + flex-wrap: wrap; +} + +.shTableFooterGroup { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.shTableSummary, +.shTableFooter { + justify-content: space-between; + color: var(--text-soft); + font-size: var(--font-size-sm); +} + +.shTableFooter .shLabel { + width: 116px; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; +} + +.shTablePageJump { + display: flex; + align-items: center; + gap: 6px; +} + +.shTableFooterGroup, +.shTablePageJump, +.shTablePageActions { + flex-wrap: wrap; +} + +.shTablePageJump .shInput { + width: 58px; + text-align: center; +} + +.shTableViewport { + flex: 0 1 auto; + min-height: 0; + overflow: auto; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--surface); + overscroll-behavior: contain; +} + +.shTableViewport .shTableHeader { + position: sticky; + top: 0; + z-index: 1; +} + .shTableRow { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); @@ -706,6 +946,39 @@ font-size: var(--font-size-sm); } +.shTableCompact .shTableHead, +.shTableCompact .shTableCell { + min-height: 30px; + padding: 5px 9px; + font-size: var(--font-size-xs); + line-height: 1.3; +} + +.shTableCompact .shTableHead { + font-weight: var(--font-weight-semibold); +} + +.shTableCompact .shTableRow { + font-size: var(--font-size-xs); +} + +.shTableCompact .shBadge { + min-height: 18px; + padding: 0 6px; + font-size: 0.6875rem; +} + +.shTableCompact .shButtonSm { + min-height: 24px; + padding: 0 8px; + font-size: var(--font-size-xs); +} + +.shTableCompact .shButtonIcon { + width: 24px; + min-height: 24px; +} + .emptyState { display: grid; min-height: 104px; @@ -726,7 +999,7 @@ background: var(--surface-subtle); color: var(--text-normal); font-family: "SFMono-Regular", Consolas, monospace; - font-size: 12px; + font-size: 0.75rem; } .modelMeta code, @@ -750,4 +1023,5 @@ .formDialogBody { grid-template-columns: 1fr; } + } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 8ead73f..d1bbf75 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -43,6 +43,15 @@ export interface ApiKeyForm { expiresAt: string; } +export interface WorkspaceTaskQuery { + query: string; + modelType: string; + createdFrom: string; + createdTo: string; + page: number; + pageSize: number; +} + export interface PlatformForm { provider: string; platformKey: string; @@ -56,6 +65,7 @@ export interface PlatformModelForm { platformId: string; canonicalModelKey: string; modelName: string; + providerModelName: string; modelAlias: string; modelType: string[]; pricingRuleSetId: string; @@ -83,6 +93,7 @@ export interface PlatformModelBindingInput { canonicalModelKey?: string; baseModelId?: string; modelName: string; + providerModelName?: string; modelAlias?: string; modelType: string[]; displayName?: string; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a8d5aff..72cad1e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -559,6 +559,7 @@ export interface PlatformModel { provider?: string; platformName?: string; modelName: string; + providerModelName?: string; modelAlias?: string; modelType: string[]; displayName: string; @@ -579,6 +580,93 @@ export interface PlatformModel { updatedAt: string; } +export interface ModelCatalogFilterOption { + value: string; + label: string; + count: number; + iconPath?: string; +} + +export interface ModelCatalogProviderSummary { + key: string; + name: string; + iconPath?: string; + sourceCount: number; +} + +export interface ModelCatalogText { + label: string; + title?: string; +} + +export interface ModelCatalogPricing { + lines: string[]; + title?: string; +} + +export interface ModelCatalogRateLimits { + rpm?: number; + tpm?: number; + concurrent?: number; + label: string; + title?: string; +} + +export interface ModelCatalogPermission { + label: string; + title?: string; + allowGroups?: string[]; + denyGroups?: string[]; +} + +export interface ModelCatalogSource { + id: string; + platformId?: string; + platformName?: string; + providerKey: string; + providerName: string; + modelName: string; + modelAlias?: string; + displayName: string; + modelType: string[]; + enabled: boolean; + rateLimits: ModelCatalogRateLimits; +} + +export interface ModelCatalogItem { + id: string; + alias: string; + displayName: string; + modelName: string; + description?: string; + iconPath?: string; + modelType: string[]; + capabilityTags: string[]; + providerKeys: string[]; + providers: ModelCatalogProviderSummary[]; + sourceCount: number; + source: ModelCatalogText; + discount: ModelCatalogText; + rateLimits: ModelCatalogRateLimits; + permission: ModelCatalogPermission; + pricing: ModelCatalogPricing; + enabled: boolean; + statusLabel?: string; + sources?: ModelCatalogSource[]; +} + +export interface ModelCatalogResponse { + items: ModelCatalogItem[]; + filters: { + capabilities: ModelCatalogFilterOption[]; + providers: ModelCatalogFilterOption[]; + }; + summary: { + modelCount: number; + sourceCount: number; + }; +} + export interface RateLimitWindow { scopeType: string; scopeKey: string; @@ -647,4 +735,7 @@ export interface GatewayTaskEvent { export interface ListResponse { items: T[]; + page?: number; + pageSize?: number; + total?: number; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ee379c..b42f19b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.0.0 version: 5.2.0(vite@7.3.3(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + antd: + specifier: ^5.29.3 + version: 5.29.3(date-fns@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -74,6 +77,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 katex: specifier: ^0.16.45 version: 0.16.45 @@ -123,6 +129,40 @@ importers: packages: + '@ant-design/colors@7.2.1': + resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} + + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': + resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@2.0.6': + resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.4.2': + resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==} + + '@ant-design/icons@5.6.1': + resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} + peerDependencies: + react: '>=16.9.0' + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -786,6 +826,12 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -1821,6 +1867,61 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rc-component/async-validator@5.1.0': + resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.3': + resolution: {integrity: sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.1': + resolution: {integrity: sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -2322,6 +2423,12 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + antd@5.29.3: + resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -2474,6 +2581,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2520,9 +2630,15 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} @@ -3124,6 +3240,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3610,6 +3729,234 @@ packages: '@types/react-dom': optional: true + rc-cascader@3.34.0: + resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.5.0: + resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.3.0: + resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.12.0: + resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.5.0: + resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.8.0: + resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.20.0: + resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.1.0: + resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.11.3: + resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.7.1: + resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.54.0: + resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.7.0: + resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.10.2: + resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.4.0: + resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.27.0: + resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.13.1: + resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.11.0: + resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + react-day-picker@10.0.0: resolution: {integrity: sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==} engines: {node: '>=18'} @@ -3760,6 +4107,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3800,6 +4150,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -3850,6 +4203,9 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3902,6 +4258,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3932,6 +4292,9 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4242,6 +4605,55 @@ packages: snapshots: + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@babel/runtime': 7.29.2 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@ant-design/cssinjs@1.24.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + stylis: 4.4.0 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.2 + + '@ant-design/icons-svg@4.4.2': {} + + '@ant-design/icons@5.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.4.2 + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@ant-design/react-slick@1.1.2(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 19.2.6 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -5080,6 +5492,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -6131,6 +6547,73 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rc-component/async-validator@5.1.0': + dependencies: + '@babel/runtime': 7.29.2 + + '@rc-component/color-picker@2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/context@1.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/mini-decimal@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + + '@rc-component/mutate-observer@1.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/portal@1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/qrcode@1.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/tour@1.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@rc-component/trigger@2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.60.3': @@ -6627,6 +7110,64 @@ snapshots: ansi-styles@5.2.0: {} + antd@5.29.3(date-fns@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ant-design/fast-color': 2.0.6 + '@ant-design/icons': 5.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@ant-design/react-slick': 1.1.2(react@19.2.6) + '@babel/runtime': 7.29.2 + '@rc-component/color-picker': 2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@rc-component/mutate-observer': 1.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@rc-component/qrcode': 1.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@rc-component/tour': 1.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + copy-to-clipboard: 3.3.3 + dayjs: 1.11.20 + rc-cascader: 3.34.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-checkbox: 3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-collapse: 3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-dialog: 9.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-drawer: 7.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-dropdown: 4.2.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-field-form: 2.7.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-image: 7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-input: 1.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-input-number: 9.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-mentions: 2.20.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-menu: 9.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-notification: 5.6.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-pagination: 5.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-picker: 4.11.3(date-fns@4.1.0)(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-progress: 4.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-rate: 2.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-segmented: 2.7.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-select: 14.16.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-slider: 11.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-steps: 6.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-switch: 4.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-table: 7.54.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tabs: 15.7.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-textarea: 1.10.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tooltip: 6.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tree: 5.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tree-select: 5.27.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-upload: 4.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -6801,6 +7342,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6838,8 +7381,14 @@ snapshots: commander@8.3.0: {} + compute-scroll-into-view@3.1.1: {} + convert-source-map@2.0.0: {} + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 @@ -7505,6 +8054,10 @@ snapshots: json-schema-traverse@1.0.0: {} + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + json5@2.2.3: {} jsonc-parser@3.2.0: {} @@ -8295,6 +8848,326 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + rc-cascader@3.34.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tree: 5.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-checkbox@3.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-collapse@3.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-dialog@9.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-drawer@7.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-dropdown@4.2.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-field-form@2.7.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/async-validator': 5.1.0 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-image@7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/portal': 1.1.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-dialog: 9.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-input-number@9.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/mini-decimal': 1.1.3 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-input@1.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-mentions@2.20.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-menu: 9.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-textarea: 1.10.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-menu@9.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-overflow: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-motion@2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-notification@5.6.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-overflow@1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-pagination@5.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-picker@4.11.3(date-fns@4.1.0)(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-overflow: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + date-fns: 4.1.0 + dayjs: 1.11.20 + + rc-progress@4.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-rate@2.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-resize-observer@1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + resize-observer-polyfill: 1.5.1 + + rc-segmented@2.7.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-select@14.16.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-overflow: 1.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-virtual-list: 3.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-slider@11.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-steps@6.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-switch@4.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-table@7.54.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/context': 1.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-virtual-list: 3.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-tabs@15.7.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-dropdown: 4.2.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-menu: 9.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-textarea@1.10.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-tooltip@6.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + '@rc-component/trigger': 2.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-tree-select@5.27.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-tree: 5.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-tree@5.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-virtual-list: 3.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-upload@4.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + rc-util@5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 18.3.1 + + rc-virtual-list@3.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/runtime': 7.29.2 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + rc-util: 5.44.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-day-picker@10.0.0(react@19.2.6): dependencies: '@date-fns/tz': 1.4.1 @@ -8481,6 +9354,8 @@ snapshots: require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve.exports@2.0.3: {} @@ -8545,6 +9420,10 @@ snapshots: scheduler@0.27.0: {} + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + secure-json-parse@4.1.0: {} semver@6.3.1: {} @@ -8606,6 +9485,8 @@ snapshots: transitivePeerDependencies: - supports-color + string-convert@0.2.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8661,6 +9542,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + throttle-debounce@5.0.2: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -8680,6 +9563,8 @@ snapshots: tmp@0.2.5: {} + toggle-selection@1.0.6: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {}