feat: improve model catalog aggregation
This commit is contained in:
@@ -127,15 +127,16 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
|||||||
Model: "openai:gpt-4o-mini",
|
Model: "openai:gpt-4o-mini",
|
||||||
Body: map[string]any{"model": "openai:gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
|
Body: map[string]any{"model": "openai:gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
|
||||||
Candidate: store.RuntimeModelCandidate{
|
Candidate: store.RuntimeModelCandidate{
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
ModelName: "gpt-4o-mini",
|
ModelName: "gpt-4o-mini",
|
||||||
Credentials: map[string]any{"apiKey": "test-key"},
|
ProviderModelName: "openai-compatible-gpt-4o-mini",
|
||||||
|
Credentials: map[string]any{"apiKey": "test-key"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("run openai client: %v", err)
|
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)
|
t.Fatalf("unexpected request path=%s auth=%s model=%s", gotPath, gotAuth, gotModel)
|
||||||
}
|
}
|
||||||
if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" {
|
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"}},
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||||
},
|
},
|
||||||
Candidate: store.RuntimeModelCandidate{
|
Candidate: store.RuntimeModelCandidate{
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
ModelName: "gemini-2.5-flash",
|
ModelName: "gemini-2.5-flash",
|
||||||
ModelType: "chat",
|
ProviderModelName: "gemini-compatible-2.5-flash",
|
||||||
Credentials: map[string]any{"apiKey": "gemini-key"},
|
ModelType: "chat",
|
||||||
|
Credentials: map[string]any{"apiKey": "gemini-key"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("run gemini client: %v", err)
|
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)
|
t.Fatalf("unexpected request path=%s key=%s text=%s", gotPath, gotKey, gotText)
|
||||||
}
|
}
|
||||||
if response.Usage.TotalTokens != 10 || extractText(response.Result) != "gemini ok" {
|
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",
|
"image": "https://example.com/source.png",
|
||||||
},
|
},
|
||||||
Candidate: store.RuntimeModelCandidate{
|
Candidate: store.RuntimeModelCandidate{
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
ModelName: "doubao-seedream-4-0-250828",
|
ModelName: "豆包Seedream-4.0",
|
||||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
ProviderModelName: "doubao-seedream-4-0-250828",
|
||||||
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||||
Capabilities: map[string]any{
|
Capabilities: map[string]any{
|
||||||
"image_edit": map[string]any{"output_multiple_images": true},
|
"image_edit": map[string]any{"output_multiple_images": true},
|
||||||
},
|
},
|
||||||
@@ -366,9 +369,10 @@ func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
|
|||||||
"aspect_ratio": "16:9",
|
"aspect_ratio": "16:9",
|
||||||
},
|
},
|
||||||
Candidate: store.RuntimeModelCandidate{
|
Candidate: store.RuntimeModelCandidate{
|
||||||
BaseURL: server.URL,
|
BaseURL: server.URL,
|
||||||
ModelName: "doubao-seedance-2-0-260128",
|
ModelName: "豆包Seedance-2.0",
|
||||||
Credentials: map[string]any{"apiKey": "volces-key"},
|
ProviderModelName: "doubao-seedance-2-0-260128",
|
||||||
|
Credentials: map[string]any{"apiKey": "volces-key"},
|
||||||
PlatformConfig: map[string]any{
|
PlatformConfig: map[string]any{
|
||||||
"volcesPollIntervalMs": 100,
|
"volcesPollIntervalMs": 100,
|
||||||
"volcesPollTimeoutSeconds": 1,
|
"volcesPollTimeoutSeconds": 1,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
}
|
}
|
||||||
body := geminiBody(request)
|
body := geminiBody(request)
|
||||||
raw, _ := json.Marshal(body)
|
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 {
|
if err != nil {
|
||||||
return Response{}, err
|
return Response{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
return Response{}, &ClientError{Code: "unsupported_kind", Message: "unsupported openai request kind", Retryable: false}
|
||||||
}
|
}
|
||||||
body := cloneBody(request.Body)
|
body := cloneBody(request.Body)
|
||||||
body["model"] = request.Candidate.ModelName
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
stream := request.Stream || boolValue(body, "stream")
|
stream := request.Stream || boolValue(body, "stream")
|
||||||
raw, _ := json.Marshal(body)
|
raw, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(request.Candidate.BaseURL, endpoint), bytes.NewReader(raw))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"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
|
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 {
|
func ErrorCode(err error) string {
|
||||||
var clientErr *ClientError
|
var clientErr *ClientError
|
||||||
if errors.As(err, &clientErr) && clientErr.Code != "" {
|
if errors.As(err, &clientErr) && clientErr.Code != "" {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func (c VolcesClient) getJSON(ctx context.Context, baseURL string, path string,
|
|||||||
|
|
||||||
func volcesImageBody(request Request) map[string]any {
|
func volcesImageBody(request Request) map[string]any {
|
||||||
body := cleanProviderBody(request.Body)
|
body := cleanProviderBody(request.Body)
|
||||||
body["model"] = request.Candidate.ModelName
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
if _, ok := body["watermark"]; !ok {
|
if _, ok := body["watermark"]; !ok {
|
||||||
body["watermark"] = false
|
body["watermark"] = false
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ func volcesImageBody(request Request) map[string]any {
|
|||||||
|
|
||||||
func volcesVideoBody(request Request) map[string]any {
|
func volcesVideoBody(request Request) map[string]any {
|
||||||
body := cleanProviderBody(request.Body)
|
body := cleanProviderBody(request.Body)
|
||||||
body["model"] = request.Candidate.ModelName
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
content := contentItems(body["content"])
|
content := contentItems(body["content"])
|
||||||
if len(content) == 0 {
|
if len(content) == 0 {
|
||||||
content = buildVolcesContentFromBody(body)
|
content = buildVolcesContentFromBody(body)
|
||||||
@@ -515,7 +515,7 @@ func volcesVideoSuccessResult(request Request, upstreamTaskID string, raw map[st
|
|||||||
"id": upstreamTaskID,
|
"id": upstreamTaskID,
|
||||||
"object": "video.generation",
|
"object": "video.generation",
|
||||||
"created": created,
|
"created": created,
|
||||||
"model": request.Candidate.ModelName,
|
"model": upstreamModelName(request.Candidate),
|
||||||
"status": "succeeded",
|
"status": "succeeded",
|
||||||
"upstream_task_id": upstreamTaskID,
|
"upstream_task_id": upstreamTaskID,
|
||||||
"data": data,
|
"data": data,
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "create platform model failed")
|
writeError(w, http.StatusInternalServerError, "create platform model failed")
|
||||||
return
|
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) {
|
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")
|
writeError(w, http.StatusInternalServerError, "replace platform models failed")
|
||||||
return
|
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) {
|
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")
|
writeError(w, http.StatusInternalServerError, "list models failed")
|
||||||
return
|
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) {
|
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")
|
writeError(w, http.StatusInternalServerError, "list playable models failed")
|
||||||
return
|
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) {
|
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")
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit := 50
|
query := r.URL.Query()
|
||||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
page, err := positiveQueryInt(query.Get("page"), 1)
|
||||||
parsed, err := strconv.Atoi(raw)
|
if err != nil {
|
||||||
if err != nil || parsed <= 0 {
|
writeError(w, http.StatusBadRequest, "invalid page")
|
||||||
writeError(w, http.StatusBadRequest, "invalid limit")
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
limit = parsed
|
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
s.logger.Error("list tasks failed", "error", err)
|
s.logger.Error("list tasks failed", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
writeError(w, http.StatusInternalServerError, "list tasks failed")
|
||||||
return
|
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 {
|
func boolValue(body map[string]any, key string) bool {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("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("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/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/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/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)))
|
mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ SELECT p.id::text, p.platform_key, p.name, p.provider,
|
|||||||
p.retry_policy, p.rate_limit_policy,
|
p.retry_policy, p.rate_limit_policy,
|
||||||
COALESCE(p.dynamic_priority, p.priority) AS effective_priority,
|
COALESCE(p.dynamic_priority, p.priority) AS effective_priority,
|
||||||
m.id::text, COALESCE(m.base_model_id::text, ''), COALESCE(b.canonical_model_key, ''),
|
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,
|
$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,
|
COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override,
|
||||||
m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''),
|
m.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 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 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
|
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'
|
WHERE p.status = 'enabled'
|
||||||
AND p.deleted_at IS NULL
|
AND p.deleted_at IS NULL
|
||||||
AND m.enabled = true
|
AND m.enabled = true
|
||||||
AND m.model_type @> jsonb_build_array($2)
|
AND m.model_type @> jsonb_build_array($2)
|
||||||
AND (p.cooldown_until IS NULL OR p.cooldown_until <= now())
|
AND (p.cooldown_until IS NULL OR p.cooldown_until <= now())
|
||||||
AND (
|
AND (
|
||||||
m.model_name = $1
|
(COALESCE(m.model_alias, '') <> '' AND m.model_alias = $1)
|
||||||
OR m.model_alias = $1
|
OR (
|
||||||
OR b.canonical_model_key = $1
|
COALESCE(m.model_alias, '') = ''
|
||||||
OR b.provider_model_name = $1
|
AND (
|
||||||
|
m.model_name = $1
|
||||||
|
OR b.canonical_model_key = $1
|
||||||
|
OR b.provider_model_name = $1
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
ORDER BY effective_priority ASC,
|
ORDER BY effective_priority ASC,
|
||||||
COALESCE(s.limiter_ratio, 0) ASC,
|
COALESCE(s.limiter_ratio, 0) ASC,
|
||||||
@@ -137,7 +142,8 @@ ORDER BY effective_priority ASC,
|
|||||||
item.RuntimeRateLimitPolicy = decodeObject(runtimeRateLimitPolicy)
|
item.RuntimeRateLimitPolicy = decodeObject(runtimeRateLimitPolicy)
|
||||||
item.AutoDisablePolicy = decodeObject(autoDisablePolicy)
|
item.AutoDisablePolicy = decodeObject(autoDisablePolicy)
|
||||||
item.DegradePolicy = decodeObject(degradePolicy)
|
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
|
item.QueueKey = item.ClientID
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ WHERE resource_type = 'platform_model'
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput) (PlatformModel, error) {
|
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)
|
base, err := s.lookupBaseModel(ctx, q, input.BaseModelID, input.CanonicalModelKey, input.ModelName)
|
||||||
if err != nil && !IsNotFound(err) {
|
if err != nil && !IsNotFound(err) {
|
||||||
return PlatformModel{}, err
|
return PlatformModel{}, err
|
||||||
@@ -93,6 +95,9 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
|
|||||||
if input.ModelName == "" {
|
if input.ModelName == "" {
|
||||||
input.ModelName = base.ProviderModelName
|
input.ModelName = base.ProviderModelName
|
||||||
}
|
}
|
||||||
|
if input.ProviderModelName == "" {
|
||||||
|
input.ProviderModelName = input.ModelName
|
||||||
|
}
|
||||||
if input.DisplayName == "" {
|
if input.DisplayName == "" {
|
||||||
input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName)
|
input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName)
|
||||||
}
|
}
|
||||||
@@ -153,19 +158,20 @@ func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier,
|
|||||||
var modelTypeBytes []byte
|
var modelTypeBytes []byte
|
||||||
err = q.QueryRow(ctx, `
|
err = q.QueryRow(ctx, `
|
||||||
INSERT INTO platform_models (
|
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,
|
capability_override, capabilities, pricing_mode, discount_factor,
|
||||||
pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy,
|
pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy,
|
||||||
runtime_policy_set_id, runtime_policy_override, enabled
|
runtime_policy_set_id, runtime_policy_override, enabled
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1::uuid, $2::uuid, $3, NULLIF($4, ''), $5::jsonb, $6,
|
$1::uuid, $2::uuid, $3, NULLIF($4, ''), NULLIF($5, ''), $6::jsonb, $7,
|
||||||
$7::jsonb, $8::jsonb, $9, $10::numeric,
|
$8::jsonb, $9::jsonb, $10, $11::numeric,
|
||||||
NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb,
|
NULLIF($12, '')::uuid, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17::jsonb,
|
||||||
NULLIF($17, '')::uuid, $18::jsonb, true
|
NULLIF($18, '')::uuid, $19::jsonb, true
|
||||||
)
|
)
|
||||||
ON CONFLICT (platform_id, model_name) DO UPDATE
|
ON CONFLICT (platform_id, model_name) DO UPDATE
|
||||||
SET base_model_id = EXCLUDED.base_model_id,
|
SET base_model_id = EXCLUDED.base_model_id,
|
||||||
|
provider_model_name = EXCLUDED.provider_model_name,
|
||||||
model_alias = EXCLUDED.model_alias,
|
model_alias = EXCLUDED.model_alias,
|
||||||
display_name = EXCLUDED.display_name,
|
display_name = EXCLUDED.display_name,
|
||||||
capability_override = EXCLUDED.capability_override,
|
capability_override = EXCLUDED.capability_override,
|
||||||
@@ -183,7 +189,7 @@ SET base_model_id = EXCLUDED.base_model_id,
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_name,
|
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,
|
capabilities, pricing_mode, COALESCE(discount_factor, 0)::float8,
|
||||||
COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config,
|
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,
|
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,
|
input.PlatformID,
|
||||||
baseID,
|
baseID,
|
||||||
input.ModelName,
|
input.ModelName,
|
||||||
|
input.ProviderModelName,
|
||||||
input.ModelAlias,
|
input.ModelAlias,
|
||||||
string(modelTypeJSON),
|
string(modelTypeJSON),
|
||||||
input.DisplayName,
|
input.DisplayName,
|
||||||
@@ -211,6 +218,7 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_
|
|||||||
&model.PlatformID,
|
&model.PlatformID,
|
||||||
&model.BaseModelID,
|
&model.BaseModelID,
|
||||||
&model.ModelName,
|
&model.ModelName,
|
||||||
|
&model.ProviderModelName,
|
||||||
&model.ModelAlias,
|
&model.ModelAlias,
|
||||||
&modelTypeBytes,
|
&modelTypeBytes,
|
||||||
&model.DisplayName,
|
&model.DisplayName,
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ type PlatformModel struct {
|
|||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
PlatformName string `json:"platformName,omitempty"`
|
PlatformName string `json:"platformName,omitempty"`
|
||||||
ModelName string `json:"modelName"`
|
ModelName string `json:"modelName"`
|
||||||
|
ProviderModelName string `json:"providerModelName,omitempty"`
|
||||||
ModelAlias string `json:"modelAlias,omitempty"`
|
ModelAlias string `json:"modelAlias,omitempty"`
|
||||||
ModelType StringList `json:"modelType"`
|
ModelType StringList `json:"modelType"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
@@ -691,7 +692,7 @@ func (s *Store) listModels(ctx context.Context, platformID string) ([]PlatformMo
|
|||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT m.id::text, m.platform_id::text, COALESCE(m.base_model_id::text, ''), p.provider, p.name,
|
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,
|
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,
|
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,
|
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.Provider,
|
||||||
&model.PlatformName,
|
&model.PlatformName,
|
||||||
&model.ModelName,
|
&model.ModelName,
|
||||||
|
&model.ProviderModelName,
|
||||||
&model.ModelAlias,
|
&model.ModelAlias,
|
||||||
&modelTypeBytes,
|
&modelTypeBytes,
|
||||||
&model.DisplayName,
|
&model.DisplayName,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -182,7 +183,7 @@ func (s *Store) PricingRuleSetBillingConfig(ctx context.Context, id string) (map
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
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
|
FROM model_pricing_rules
|
||||||
WHERE rule_set_id = $1::uuid
|
WHERE rule_set_id = $1::uuid
|
||||||
AND status = 'active'
|
AND status = 'active'
|
||||||
@@ -197,15 +198,31 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
|||||||
var resourceType string
|
var resourceType string
|
||||||
var basePrice float64
|
var basePrice float64
|
||||||
var dynamicWeightBytes []byte
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
dynamicWeight := decodeObject(dynamicWeightBytes)
|
dynamicWeight := decodeObject(dynamicWeightBytes)
|
||||||
|
formulaConfig := decodeObject(formulaConfigBytes)
|
||||||
switch resourceType {
|
switch resourceType {
|
||||||
case "text_input":
|
case "text_input":
|
||||||
config["textInputPer1k"] = basePrice
|
config["textInputPer1k"] = basePrice
|
||||||
case "text_output":
|
case "text_output":
|
||||||
config["textOutputPer1k"] = basePrice
|
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":
|
case "image":
|
||||||
config["imageBase"] = basePrice
|
config["imageBase"] = basePrice
|
||||||
config["image"] = pricingResourceConfig(basePrice, dynamicWeight)
|
config["image"] = pricingResourceConfig(basePrice, dynamicWeight)
|
||||||
@@ -225,6 +242,45 @@ ORDER BY priority ASC, resource_type ASC`, id)
|
|||||||
return config, nil
|
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 {
|
func pricingResourceConfig(basePrice float64, dynamicWeight map[string]any) map[string]any {
|
||||||
config := map[string]any{"basePrice": basePrice}
|
config := map[string]any{"basePrice": basePrice}
|
||||||
if len(dynamicWeight) > 0 {
|
if len(dynamicWeight) > 0 {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type CreatePlatformModelInput struct {
|
|||||||
BaseModelID string `json:"baseModelId"`
|
BaseModelID string `json:"baseModelId"`
|
||||||
CanonicalModelKey string `json:"canonicalModelKey"`
|
CanonicalModelKey string `json:"canonicalModelKey"`
|
||||||
ModelName string `json:"modelName"`
|
ModelName string `json:"modelName"`
|
||||||
|
ProviderModelName string `json:"providerModelName"`
|
||||||
ModelAlias string `json:"modelAlias"`
|
ModelAlias string `json:"modelAlias"`
|
||||||
ModelType StringList `json:"modelType"`
|
ModelType StringList `json:"modelType"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
|||||||
@@ -11,13 +11,35 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]GatewayTask, error) {
|
type TaskListFilter struct {
|
||||||
if limit <= 0 {
|
Query string
|
||||||
limit = 50
|
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 {
|
pageSize := filter.PageSize
|
||||||
limit = 100
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
}
|
}
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
gatewayUserID := localGatewayUserID(user)
|
gatewayUserID := localGatewayUserID(user)
|
||||||
apiKeyID := ""
|
apiKeyID := ""
|
||||||
userID := ""
|
userID := ""
|
||||||
@@ -26,11 +48,22 @@ func (s *Store) ListTasks(ctx context.Context, user *auth.User, limit int) ([]Ga
|
|||||||
userID = strings.TrimSpace(user.ID)
|
userID = strings.TrimSpace(user.ID)
|
||||||
}
|
}
|
||||||
if gatewayUserID == "" && userID == "" {
|
if gatewayUserID == "" && userID == "" {
|
||||||
return nil, ErrLocalUserRequired
|
return TaskListResult{}, ErrLocalUserRequired
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx, `
|
queryPattern := ""
|
||||||
SELECT `+gatewayTaskColumns+`
|
if query := strings.TrimSpace(filter.Query); query != "" {
|
||||||
FROM gateway_tasks
|
queryPattern = "%" + query + "%"
|
||||||
|
}
|
||||||
|
args := []any{
|
||||||
|
gatewayUserID,
|
||||||
|
userID,
|
||||||
|
apiKeyID,
|
||||||
|
queryPattern,
|
||||||
|
strings.TrimSpace(filter.ModelType),
|
||||||
|
nullableTaskListTime(filter.CreatedFrom),
|
||||||
|
nullableTaskListTime(filter.CreatedTo),
|
||||||
|
}
|
||||||
|
whereSQL := `
|
||||||
WHERE (
|
WHERE (
|
||||||
(
|
(
|
||||||
NULLIF($1, '')::uuid IS NOT NULL
|
NULLIF($1, '')::uuid IS NOT NULL
|
||||||
@@ -46,10 +79,45 @@ WHERE (
|
|||||||
NULLIF($3, '') IS NULL
|
NULLIF($3, '') IS NULL
|
||||||
OR api_key_id = $3
|
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
|
ORDER BY created_at DESC
|
||||||
LIMIT $4`, gatewayUserID, userID, apiKeyID, limit)
|
LIMIT $8 OFFSET $9`, queryArgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return TaskListResult{}, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -57,11 +125,26 @@ LIMIT $4`, gatewayUserID, userID, apiKeyID, limit)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
task, err := scanGatewayTask(rows)
|
task, err := scanGatewayTask(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return TaskListResult{}, err
|
||||||
}
|
}
|
||||||
items = append(items, task)
|
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 {
|
func (s *Store) MarkTaskRunning(ctx context.Context, taskID string, modelType string, normalizedRequest map[string]any) error {
|
||||||
|
|||||||
@@ -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) = '';
|
||||||
@@ -23,9 +23,11 @@
|
|||||||
"@streamdown/math": "^1.0.2",
|
"@streamdown/math": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"antd": "^5.29.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"katex": "^0.16.45",
|
"katex": "^0.16.45",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+60
-9
@@ -12,6 +12,7 @@ import type {
|
|||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
|
ModelCatalogResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
PricingRuleSet,
|
PricingRuleSet,
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
listApiKeys,
|
listApiKeys,
|
||||||
listBaseModels,
|
listBaseModels,
|
||||||
listCatalogProviders,
|
listCatalogProviders,
|
||||||
|
listModelCatalog,
|
||||||
listModels,
|
listModels,
|
||||||
listPlayableApiKeys,
|
listPlayableApiKeys,
|
||||||
listPlayableModels,
|
listPlayableModels,
|
||||||
@@ -87,6 +89,8 @@ import {
|
|||||||
pathForPage,
|
pathForPage,
|
||||||
pathForPlaygroundMode,
|
pathForPlaygroundMode,
|
||||||
pathForWorkspaceSection,
|
pathForWorkspaceSection,
|
||||||
|
pathForWorkspaceTaskQuery,
|
||||||
|
workspaceTaskQueryKey,
|
||||||
type AppRouteState,
|
type AppRouteState,
|
||||||
} from './routing';
|
} from './routing';
|
||||||
import type {
|
import type {
|
||||||
@@ -103,6 +107,7 @@ import type {
|
|||||||
PlatformWithModelsInput,
|
PlatformWithModelsInput,
|
||||||
RegisterForm,
|
RegisterForm,
|
||||||
TaskForm,
|
TaskForm,
|
||||||
|
WorkspaceTaskQuery,
|
||||||
WorkspaceSection,
|
WorkspaceSection,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -111,6 +116,7 @@ type DataKey =
|
|||||||
| 'publicCatalog'
|
| 'publicCatalog'
|
||||||
| 'playgroundApiKeys'
|
| 'playgroundApiKeys'
|
||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
|
| 'modelCatalog'
|
||||||
| 'platforms'
|
| 'platforms'
|
||||||
| 'models'
|
| 'models'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
@@ -131,6 +137,7 @@ export function App() {
|
|||||||
const [activePage, setActivePage] = useState<PageKey>(initialRoute.activePage);
|
const [activePage, setActivePage] = useState<PageKey>(initialRoute.activePage);
|
||||||
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
||||||
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
||||||
|
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
|
||||||
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
|
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
|
||||||
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
|
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
|
||||||
const [token, setToken] = useState(readStoredAccessToken);
|
const [token, setToken] = useState(readStoredAccessToken);
|
||||||
@@ -141,6 +148,11 @@ export function App() {
|
|||||||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||||||
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
|
||||||
const [models, setModels] = useState<PlatformModel[]>([]);
|
const [models, setModels] = useState<PlatformModel[]>([]);
|
||||||
|
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse>({
|
||||||
|
items: [],
|
||||||
|
filters: { capabilities: [], providers: [] },
|
||||||
|
summary: { modelCount: 0, sourceCount: 0 },
|
||||||
|
});
|
||||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||||
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
||||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||||
@@ -160,12 +172,15 @@ export function App() {
|
|||||||
const [taskForm, setTaskForm] = useState<TaskForm>({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' });
|
const [taskForm, setTaskForm] = useState<TaskForm>({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' });
|
||||||
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
||||||
const [tasks, setTasks] = useState<GatewayTask[]>([]);
|
const [tasks, setTasks] = useState<GatewayTask[]>([]);
|
||||||
|
const [taskTotal, setTaskTotal] = useState(0);
|
||||||
const [coreState, setCoreState] = useState<LoadState>('idle');
|
const [coreState, setCoreState] = useState<LoadState>('idle');
|
||||||
const [coreMessage, setCoreMessage] = useState('');
|
const [coreMessage, setCoreMessage] = useState('');
|
||||||
const [state, setState] = useState<LoadState>('idle');
|
const [state, setState] = useState<LoadState>('idle');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const loadedDataKeysRef = useRef(new Set<DataKey>());
|
const loadedDataKeysRef = useRef(new Set<DataKey>());
|
||||||
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
||||||
|
const loadedTaskQueryKeyRef = useRef('');
|
||||||
|
const currentTaskQueryKeyRef = useRef('');
|
||||||
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
|
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
|
||||||
setBaseModels,
|
setBaseModels,
|
||||||
setCoreMessage,
|
setCoreMessage,
|
||||||
@@ -185,13 +200,15 @@ export function App() {
|
|||||||
setRuntimePolicySets,
|
setRuntimePolicySets,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
|
const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery);
|
||||||
|
currentTaskQueryKeyRef.current = taskListRequestKey;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureData(['health']);
|
void ensureData(['health']);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureRouteData(token);
|
void ensureRouteData(token);
|
||||||
}, [activePage, adminSection, workspaceSection, token]);
|
}, [activePage, adminSection, taskListRequestKey, workspaceSection, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handlePopState() {
|
function handlePopState() {
|
||||||
applyRoute(parseAppRoute());
|
applyRoute(parseAppRoute());
|
||||||
@@ -223,6 +240,7 @@ export function App() {
|
|||||||
accessRules,
|
accessRules,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
baseModels,
|
baseModels,
|
||||||
|
modelCatalog,
|
||||||
models,
|
models,
|
||||||
platforms,
|
platforms,
|
||||||
pricingRules,
|
pricingRules,
|
||||||
@@ -235,7 +253,7 @@ export function App() {
|
|||||||
tenants,
|
tenants,
|
||||||
userGroups,
|
userGroups,
|
||||||
users,
|
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) {
|
async function refresh(nextToken = token) {
|
||||||
await ensureRouteData(nextToken, true);
|
await ensureRouteData(nextToken, true);
|
||||||
@@ -246,6 +264,10 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureRouteData(nextToken = token, force = false) {
|
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);
|
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +316,9 @@ export function App() {
|
|||||||
case 'models':
|
case 'models':
|
||||||
setModels((await listModels(nextToken)).items);
|
setModels((await listModels(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
|
case 'modelCatalog':
|
||||||
|
setModelCatalog(await listModelCatalog(nextToken));
|
||||||
|
return;
|
||||||
case 'playgroundModels':
|
case 'playgroundModels':
|
||||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
@@ -332,7 +357,14 @@ export function App() {
|
|||||||
setUserGroups((await listUserGroups(nextToken)).items);
|
setUserGroups((await listUserGroups(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
case 'tasks':
|
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;
|
return;
|
||||||
case 'accessRules':
|
case 'accessRules':
|
||||||
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
|
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
|
||||||
@@ -420,6 +452,7 @@ export function App() {
|
|||||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||||
|
invalidateDataKeys('modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(input.platformId
|
setCoreMessage(input.platformId
|
||||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||||
@@ -438,6 +471,7 @@ export function App() {
|
|||||||
await deletePlatform(token, platformId);
|
await deletePlatform(token, platformId);
|
||||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||||
|
invalidateDataKeys('modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('平台已删除。');
|
setCoreMessage('平台已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -514,6 +548,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||||
|
invalidateDataKeys('modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -531,6 +566,7 @@ export function App() {
|
|||||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||||
|
invalidateDataKeys('modelCatalog');
|
||||||
invalidateDataKeys('playgroundModels');
|
invalidateDataKeys('playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('用户组已删除。');
|
setCoreMessage('用户组已删除。');
|
||||||
@@ -569,7 +605,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||||
invalidateDataKeys('playgroundModels');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -585,7 +621,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
await deleteAccessRule(token, ruleId);
|
await deleteAccessRule(token, ruleId);
|
||||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||||
invalidateDataKeys('playgroundModels');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限规则已删除。');
|
setCoreMessage('访问权限规则已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -601,7 +637,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await batchAccessRules(token, input);
|
const response = await batchAccessRules(token, input);
|
||||||
setAccessRules(response.items);
|
setAccessRules(response.items);
|
||||||
invalidateDataKeys('playgroundModels');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限已更新。');
|
setCoreMessage('访问权限已更新。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -653,6 +689,7 @@ export function App() {
|
|||||||
setState('idle');
|
setState('idle');
|
||||||
setPlatforms([]);
|
setPlatforms([]);
|
||||||
setModels([]);
|
setModels([]);
|
||||||
|
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
||||||
setPlaygroundModels([]);
|
setPlaygroundModels([]);
|
||||||
setProviders([]);
|
setProviders([]);
|
||||||
setBaseModels([]);
|
setBaseModels([]);
|
||||||
@@ -670,6 +707,7 @@ export function App() {
|
|||||||
setSelectedPlaygroundApiKeyId('');
|
setSelectedPlaygroundApiKeyId('');
|
||||||
setTaskResult(null);
|
setTaskResult(null);
|
||||||
setTasks([]);
|
setTasks([]);
|
||||||
|
setTaskTotal(0);
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
navigatePath('/');
|
navigatePath('/');
|
||||||
}
|
}
|
||||||
@@ -680,7 +718,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentRouteState(): AppRouteState {
|
function currentRouteState(): AppRouteState {
|
||||||
return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection };
|
return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection, workspaceTaskQuery };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRoute(route: AppRouteState) {
|
function applyRoute(route: AppRouteState) {
|
||||||
@@ -689,10 +727,11 @@ export function App() {
|
|||||||
setApiDocSection(route.apiDocSection);
|
setApiDocSection(route.apiDocSection);
|
||||||
setPlaygroundMode(route.playgroundMode);
|
setPlaygroundMode(route.playgroundMode);
|
||||||
setWorkspaceSection(route.workspaceSection);
|
setWorkspaceSection(route.workspaceSection);
|
||||||
|
setWorkspaceTaskQuery(route.workspaceTaskQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigatePath(path: string) {
|
function navigatePath(path: string) {
|
||||||
if (window.location.pathname !== path) {
|
if (`${window.location.pathname}${window.location.search}` !== path) {
|
||||||
window.history.pushState(null, '', path);
|
window.history.pushState(null, '', path);
|
||||||
}
|
}
|
||||||
applyRoute(parseAppRoute(path));
|
applyRoute(parseAppRoute(path));
|
||||||
@@ -710,6 +749,10 @@ export function App() {
|
|||||||
navigatePath(pathForWorkspaceSection(section));
|
navigatePath(pathForWorkspaceSection(section));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateWorkspaceTaskQuery(query: WorkspaceTaskQuery) {
|
||||||
|
navigatePath(pathForWorkspaceTaskQuery(query));
|
||||||
|
}
|
||||||
|
|
||||||
function navigateApiDocSection(section: ApiDocSection) {
|
function navigateApiDocSection(section: ApiDocSection) {
|
||||||
navigatePath(pathForApiDocSection(section));
|
navigatePath(pathForApiDocSection(section));
|
||||||
}
|
}
|
||||||
@@ -768,11 +811,14 @@ export function App() {
|
|||||||
message={coreMessage}
|
message={coreMessage}
|
||||||
section={workspaceSection}
|
section={workspaceSection}
|
||||||
state={coreState}
|
state={coreState}
|
||||||
|
taskQuery={workspaceTaskQuery}
|
||||||
|
taskTotal={taskTotal}
|
||||||
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
||||||
onDeleteApiKey={removeAPIKey}
|
onDeleteApiKey={removeAPIKey}
|
||||||
onApiKeyFormChange={setApiKeyForm}
|
onApiKeyFormChange={setApiKeyForm}
|
||||||
onSectionChange={navigateWorkspaceSection}
|
onSectionChange={navigateWorkspaceSection}
|
||||||
onSubmitApiKey={submitAPIKey}
|
onSubmitApiKey={submitAPIKey}
|
||||||
|
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
||||||
onUseApiKeyForPlayground={useApiKeyForPlayground}
|
onUseApiKeyForPlayground={useApiKeyForPlayground}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -877,6 +923,7 @@ function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, curre
|
|||||||
if (!existing) return input;
|
if (!existing) return input;
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
|
providerModelName: input.providerModelName ?? existing.providerModelName,
|
||||||
discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined,
|
discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined,
|
||||||
pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId,
|
pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId,
|
||||||
rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy,
|
rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy,
|
||||||
@@ -933,7 +980,11 @@ function dataKeysForRoute(
|
|||||||
isAuthenticated: boolean,
|
isAuthenticated: boolean,
|
||||||
): DataKey[] {
|
): DataKey[] {
|
||||||
if (activePage === 'playground') return isAuthenticated ? ['playgroundModels', 'playgroundApiKeys'] : [];
|
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 (activePage === 'home' || activePage === 'docs') return [];
|
||||||
if (!isAuthenticated) return [];
|
if (!isAuthenticated) return [];
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -16,6 +16,7 @@ import type {
|
|||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
|
ModelCatalogResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PlayableGatewayApiKey,
|
PlayableGatewayApiKey,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
@@ -27,7 +28,7 @@ import type {
|
|||||||
UserGroup,
|
UserGroup,
|
||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} 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';
|
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<ListResponse<Pl
|
|||||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listModelCatalog(token: string): Promise<ModelCatalogResponse> {
|
||||||
|
return request<ModelCatalogResponse>('/api/v1/model-catalog', { token });
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
|
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
|
||||||
return request<ListResponse<CatalogProvider>>('/api/v1/public/catalog/providers', { auth: false });
|
return request<ListResponse<CatalogProvider>>('/api/v1/public/catalog/providers', { auth: false });
|
||||||
}
|
}
|
||||||
@@ -582,8 +587,16 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
|
|||||||
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTasks(token: string, limit = 50): Promise<ListResponse<GatewayTask>> {
|
export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise<ListResponse<GatewayTask>> {
|
||||||
return request<ListResponse<GatewayTask>>(`/api/v1/tasks?limit=${encodeURIComponent(String(limit))}`, { token });
|
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<ListResponse<GatewayTask>>(`/api/v1/tasks?${search.toString()}`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveApiAssetUrl(src: string) {
|
export function resolveApiAssetUrl(src: string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
|
ModelCatalogResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
PricingRule,
|
PricingRule,
|
||||||
PricingRuleSet,
|
PricingRuleSet,
|
||||||
@@ -19,6 +20,7 @@ export interface ConsoleData {
|
|||||||
accessRules: GatewayAccessRule[];
|
accessRules: GatewayAccessRule[];
|
||||||
apiKeys: GatewayApiKey[];
|
apiKeys: GatewayApiKey[];
|
||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
|
modelCatalog: ModelCatalogResponse;
|
||||||
models: PlatformModel[];
|
models: PlatformModel[];
|
||||||
platforms: IntegrationPlatform[];
|
platforms: IntegrationPlatform[];
|
||||||
pricingRules: PricingRule[];
|
pricingRules: PricingRule[];
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
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 { CalendarIcon, X } from 'lucide-react';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
import { Calendar } from './calendar';
|
import { Calendar } from './calendar';
|
||||||
@@ -7,7 +13,11 @@ import { Input } from './input';
|
|||||||
import { Label } from './label';
|
import { Label } from './label';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
export function DateTimePicker(props: {
|
export function DateTimePicker(props: {
|
||||||
|
clearLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -54,7 +64,7 @@ export function DateTimePicker(props: {
|
|||||||
时间
|
时间
|
||||||
<Input type="time" value={timeValue} onChange={(event) => updateTime(event.target.value)} />
|
<Input type="time" value={timeValue} onChange={(event) => updateTime(event.target.value)} />
|
||||||
</Label>
|
</Label>
|
||||||
<Button type="button" variant="ghost" size="icon" title="清除有效期" disabled={!props.value} onClick={() => props.onChange('')}>
|
<Button type="button" variant="ghost" size="icon" title={props.clearLabel ?? '清除有效期'} disabled={!props.value} onClick={() => props.onChange('')}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<ConfigProvider locale={zhCN} theme={dateRangePickerTheme}>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
allowClear
|
||||||
|
className="shDateRangePicker"
|
||||||
|
classNames={{ popup: { root: 'shDateRangePickerPopup' } }}
|
||||||
|
disabled={props.disabled}
|
||||||
|
format={rangeDateTimeFormat}
|
||||||
|
needConfirm
|
||||||
|
placeholder={[props.fromPlaceholder ?? '开始日期', props.toPlaceholder ?? '结束日期']}
|
||||||
|
showTime={{ format: 'HH:mm:ss' }}
|
||||||
|
size="middle"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={value}
|
||||||
|
onChange={(nextValue) => {
|
||||||
|
props.onChange({
|
||||||
|
from: nextValue?.[0] ? nextValue[0].format(rangeDateTimeFormat) : '',
|
||||||
|
to: nextValue?.[1] ? nextValue[1].format(rangeDateTimeFormat) : '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function parseLocalDateTime(value: string) {
|
||||||
if (!value) return undefined;
|
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;
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +151,14 @@ function formatTimeValue(date: Date) {
|
|||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
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) {
|
function pad(value: number) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
export function Table(props: React.HTMLAttributes<HTMLDivElement>) {
|
export type TableDensity = 'standard' | 'compact';
|
||||||
|
|
||||||
|
export interface TableProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
density?: TableDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table(props: TableProps) {
|
||||||
|
const { className, density = 'standard', ...rest } = props;
|
||||||
|
return <div className={cn('shTable', density === 'compact' ? 'shTableCompact' : 'shTableStandard', className)} role="table" {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableViewportLayout(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { className, ...rest } = props;
|
const { className, ...rest } = props;
|
||||||
return <div className={cn('shTable', className)} role="table" {...rest} />;
|
return <div className={cn('shTableViewportLayout', className)} {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableToolbar(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTableToolbar', className)} {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSummary(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTableSummary', className)} {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableFooter(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTableFooter', className)} {...rest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePageActions(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
return <div className={cn('shTablePageActions', className)} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
|
export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
@@ -11,14 +42,14 @@ export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
|
|||||||
return <div className={cn('shTableRow', className)} role="row" {...rest} />;
|
return <div className={cn('shTableRow', className)} role="row" {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableHead(props: React.HTMLAttributes<HTMLSpanElement>) {
|
export function TableHead(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { className, ...rest } = props;
|
const { className, ...rest } = props;
|
||||||
return <span className={cn('shTableHead', className)} role="columnheader" {...rest} />;
|
return <div className={cn('shTableHead', className)} role="columnheader" {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableCell(props: React.HTMLAttributes<HTMLSpanElement>) {
|
export function TableCell(props: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const { className, ...rest } = props;
|
const { className, ...rest } = props;
|
||||||
return <span className={cn('shTableCell', className)} role="cell" {...rest} />;
|
return <div className={cn('shTableCell', className)} role="cell" {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState(props: { title: string; description?: string }) {
|
export function EmptyState(props: { title: string; description?: string }) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import 'streamdown/styles.css';
|
import 'streamdown/styles.css';
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
+123
-264
@@ -1,156 +1,37 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Boxes, Search } from 'lucide-react';
|
import { Boxes, Check, Search, X } from 'lucide-react';
|
||||||
import type {
|
import type { ModelCatalogFilterOption, ModelCatalogItem, ModelCatalogPermission } from '@easyai-ai-gateway/contracts';
|
||||||
BaseModelCatalogItem,
|
|
||||||
BillingConfig,
|
|
||||||
CatalogProvider,
|
|
||||||
PlatformModel,
|
|
||||||
} from '@easyai-ai-gateway/contracts';
|
|
||||||
import type { ConsoleData } from '../app-state';
|
import type { ConsoleData } from '../app-state';
|
||||||
import { Badge, Card, CardContent, Input } from '../components/ui';
|
import { 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<string, unknown>;
|
|
||||||
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: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ModelsPage(props: { data: ConsoleData }) {
|
export function ModelsPage(props: { data: ConsoleData }) {
|
||||||
|
const catalog = props.data.modelCatalog;
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [provider, setProvider] = useState('all');
|
const [provider, setProvider] = useState('all');
|
||||||
const [capability, setCapability] = useState('all');
|
const [capability, setCapability] = useState('all');
|
||||||
const sourceProviders = props.data.providers.length ? props.data.providers : publicProviders;
|
const providerOptions = catalog.filters.providers.length ? catalog.filters.providers : [{ value: 'all', label: '全部', count: catalog.items.length }];
|
||||||
const providerMap = useMemo(() => buildProviderMap(sourceProviders), [sourceProviders]);
|
const capabilityOptions = catalog.filters.capabilities.length ? catalog.filters.capabilities : [{ value: 'all', label: '全部', count: catalog.items.length }];
|
||||||
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 = useMemo(() => {
|
useEffect(() => {
|
||||||
const options = new Map<string, string>();
|
if (!providerOptions.some((item) => item.value === provider)) setProvider('all');
|
||||||
sourceProviders
|
}, [provider, providerOptions]);
|
||||||
.filter((item) => item.status !== 'hidden')
|
|
||||||
.forEach((item) => options.set(item.providerKey, item.displayName));
|
useEffect(() => {
|
||||||
sourceModels.forEach((model) => {
|
if (!capabilityOptions.some((item) => item.value === capability)) setCapability('all');
|
||||||
if (!options.has(model.providerKey)) {
|
}, [capability, capabilityOptions]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModels = useMemo(() => {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
return sourceModels.filter((model) => {
|
return catalog.items.filter((model) => {
|
||||||
const matchedProvider = provider === 'all' || model.providerKey === provider;
|
const matchedProvider = provider === 'all' || model.providerKeys.includes(provider);
|
||||||
const matchedCapability = modelMatchesCapability(model.modelType, capability);
|
const matchedCapability = capability === 'all' || modelMatchesCapability(model, capability);
|
||||||
const matchedQuery = [
|
const matchedQuery = !normalizedQuery || [
|
||||||
model.modelName,
|
model.alias,
|
||||||
model.modelAlias,
|
|
||||||
model.displayName,
|
model.displayName,
|
||||||
|
model.modelName,
|
||||||
|
model.description,
|
||||||
|
...model.providers.map((item) => item.name),
|
||||||
|
...model.capabilityTags,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -158,14 +39,14 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
|||||||
.includes(normalizedQuery);
|
.includes(normalizedQuery);
|
||||||
return matchedProvider && matchedCapability && matchedQuery;
|
return matchedProvider && matchedCapability && matchedQuery;
|
||||||
});
|
});
|
||||||
}, [capability, provider, query, sourceModels]);
|
}, [capability, catalog.items, provider, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modelsPage">
|
<div className="modelsPage">
|
||||||
<aside className="modelFilters">
|
<aside className="modelFilters">
|
||||||
<FilterGroup
|
<FilterGroup
|
||||||
title="模型能力"
|
title="模型能力"
|
||||||
items={capabilityFilters}
|
items={capabilityOptions}
|
||||||
value={capability}
|
value={capability}
|
||||||
onChange={setCapability}
|
onChange={setCapability}
|
||||||
/>
|
/>
|
||||||
@@ -179,16 +60,16 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
|||||||
|
|
||||||
<main className="modelsContent">
|
<main className="modelsContent">
|
||||||
<div className="modelsToolbar">
|
<div className="modelsToolbar">
|
||||||
<p>共 {sourceModels.length} 个模型,当前显示 {filteredModels.length} 个</p>
|
<p>共 {catalog.summary.sourceCount} 个源,按别名合并为 {catalog.summary.modelCount} 个模型,当前显示 {filteredModels.length} 个</p>
|
||||||
<div className="searchField modelHeaderSearch">
|
<div className="searchField modelHeaderSearch">
|
||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称模糊搜索" />
|
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称、能力或厂商" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="modelCards">
|
<section className="modelCards">
|
||||||
{filteredModels.map((model) => (
|
{filteredModels.map((model) => (
|
||||||
<ModelCard model={model} provider={providerMap.get(model.providerKey)} key={model.id} />
|
<ModelCard model={model} key={model.id} />
|
||||||
))}
|
))}
|
||||||
{!filteredModels.length && (
|
{!filteredModels.length && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -205,7 +86,7 @@ export function ModelsPage(props: { data: ConsoleData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilterGroup(props: {
|
function FilterGroup(props: {
|
||||||
items: Array<{ value: string; label: string }>;
|
items: ModelCatalogFilterOption[];
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -222,7 +103,9 @@ function FilterGroup(props: {
|
|||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => props.onChange(item.value)}
|
onClick={() => props.onChange(item.value)}
|
||||||
>
|
>
|
||||||
{item.label}
|
<FilterIcon item={item} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<em>{item.count}</em>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,104 +113,118 @@ function FilterGroup(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelCard(props: { model: ModelListItem; provider?: CatalogProvider }) {
|
function FilterIcon(props: { item: ModelCatalogFilterOption }) {
|
||||||
const tags = tagsForModel(props.model);
|
if (props.item.iconPath) {
|
||||||
const providerName = props.provider?.displayName ?? props.model.providerKey;
|
return (
|
||||||
|
<span className="filterChipIcon">
|
||||||
|
<img src={props.item.iconPath} alt="" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.item.value === 'all') return null;
|
||||||
|
return <span className="filterChipIcon">{providerInitials(props.item.label)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelCard(props: { model: ModelCatalogItem }) {
|
||||||
|
const description = props.model.description || '暂无模型描述';
|
||||||
return (
|
return (
|
||||||
<Card className="modelCard">
|
<Card className="modelCard">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="modelCardTop">
|
<div className="modelCardTop">
|
||||||
<ProviderIcon provider={props.provider} label={providerName} />
|
<ModelIcon iconPath={props.model.iconPath} label={props.model.displayName || props.model.alias} />
|
||||||
<div>
|
<div className="modelCardHeaderText">
|
||||||
<strong>{props.model.displayName || props.model.modelName}</strong>
|
<strong>{props.model.displayName || props.model.alias}</strong>
|
||||||
<span>{providerName} · {props.model.platformName ?? props.provider?.code ?? 'catalog'}</span>
|
<p className="modelCardDescription text-xs" title={description}>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={props.model.enabled ? 'success' : 'secondary'}>
|
|
||||||
{props.model.enabled ? '启用' : '停用'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<p>{props.model.modelAlias || props.model.modelName}</p>
|
|
||||||
<div className="modelTags">
|
<div className="modelCardIntro">
|
||||||
{tags.map((tag) => <span key={tag}>{tag}</span>)}
|
<div className="modelTags">
|
||||||
</div>
|
{props.model.capabilityTags.map((tag) => <span className="text-xs" key={tag}>{tag}</span>)}
|
||||||
<div className="modelCardFooter">
|
</div>
|
||||||
<span>{priceLabel(props.model)}</span>
|
|
||||||
<a href="#docs">查看详情</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dl className="modelCardFacts text-xs">
|
||||||
|
<div>
|
||||||
|
<dt>源</dt>
|
||||||
|
<dd title={props.model.source.title}>{props.model.source.label}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>折扣率</dt>
|
||||||
|
<dd title={props.model.discount.title}>{props.model.discount.label}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="modelCardFactRateLimit">
|
||||||
|
<dt>限流</dt>
|
||||||
|
<dd title={props.model.rateLimits.title}>{props.model.rateLimits.label}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>权限要求</dt>
|
||||||
|
<dd title={props.model.permission.title}>
|
||||||
|
<PermissionValue permission={props.model.permission} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="modelCardFactFull">
|
||||||
|
<dt>模型定价</dt>
|
||||||
|
<dd title={props.model.pricing.title}>{props.model.pricing.lines.join(';')}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderIcon(props: { provider?: CatalogProvider; label?: string }) {
|
function PermissionValue(props: { permission: ModelCatalogPermission }) {
|
||||||
const label = props.label ?? props.provider?.displayName ?? props.provider?.providerKey ?? 'AI';
|
const allowGroups = props.permission.allowGroups ?? [];
|
||||||
if (props.provider?.iconPath) {
|
const denyGroups = props.permission.denyGroups ?? [];
|
||||||
|
if (!allowGroups.length && !denyGroups.length) {
|
||||||
|
return <span>{props.permission.label}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="modelPermissionTags">
|
||||||
|
{allowGroups.map((group) => (
|
||||||
|
<span className="modelPermissionTag modelPermissionTagAllow" key={`allow-${group}`}>
|
||||||
|
<Check aria-hidden="true" className="modelPermissionIcon" />
|
||||||
|
<span>{group}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{denyGroups.map((group) => (
|
||||||
|
<span className="modelPermissionTag modelPermissionTagDeny" key={`deny-${group}`}>
|
||||||
|
<X aria-hidden="true" className="modelPermissionIcon" />
|
||||||
|
<span>{group}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelIcon(props: { iconPath?: string; label: string }) {
|
||||||
|
if (props.iconPath) {
|
||||||
return (
|
return (
|
||||||
<div className="modelIcon modelIconImage">
|
<div className="modelIcon modelIconImage">
|
||||||
<img src={props.provider.iconPath} alt="" />
|
<img src={props.iconPath} alt="" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <div className="modelIcon">{providerInitials(label)}</div>;
|
return <div className="modelIcon">{providerInitials(props.label)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderMap(providers: CatalogProvider[]) {
|
function modelMatchesCapability(model: ModelCatalogItem, capability: string) {
|
||||||
const map = new Map<string, CatalogProvider>();
|
if (model.modelType.some((type) => modelTypeMatchesCapability(type, capability))) return true;
|
||||||
providers.forEach((provider) => {
|
if (capability === 'tools') return model.capabilityTags.includes('工具调用');
|
||||||
[
|
if (capability === 'omni') return model.capabilityTags.includes('全模态');
|
||||||
provider.providerKey,
|
return false;
|
||||||
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 modelFromPlatform(model: PlatformModel): ModelListItem {
|
function modelTypeMatchesCapability(value: string, capability: string) {
|
||||||
return {
|
const type = value.trim().toLowerCase();
|
||||||
id: model.id,
|
if (capability === 'chat') return type === 'text_generate' || type === 'chat' || type === 'responses' || type.includes('text');
|
||||||
providerKey: normalizeProviderKey(model.provider ?? model.platformName ?? ''),
|
if (capability === 'image') return type.includes('image') && !type.includes('video');
|
||||||
platformName: model.platformName,
|
if (capability === 'video') return type.includes('video');
|
||||||
modelName: model.modelName,
|
if (capability === 'audio') return type.includes('audio') || type.includes('speech');
|
||||||
modelAlias: model.modelAlias,
|
if (capability === 'embedding') return type === 'text_embedding' || type === 'embedding';
|
||||||
modelType: model.modelType,
|
if (capability === 'tools') return type === 'tools_call';
|
||||||
displayName: model.displayName,
|
if (capability === 'omni') return type === 'omni' || type === 'omni_video';
|
||||||
capabilities: model.capabilities ?? model.capabilityOverride,
|
return type === capability;
|
||||||
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<string, unknown> | undefined, key: string) {
|
|
||||||
const value = metadata?.[key];
|
|
||||||
return typeof value === 'string' ? value : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerInitials(label: string) {
|
function providerInitials(label: string) {
|
||||||
@@ -338,41 +235,3 @@ function providerInitials(label: string) {
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.toUpperCase() || 'AI';
|
.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<string, string> = {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-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 { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
import type { ConsoleData } from '../app-state';
|
import type { ConsoleData } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
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 { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||||
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
|
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||||
@@ -14,6 +14,8 @@ const tabs = [
|
|||||||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||||||
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
||||||
|
|
||||||
|
const taskPageSizeOptions = [10, 20, 50];
|
||||||
|
|
||||||
export function WorkspacePage(props: {
|
export function WorkspacePage(props: {
|
||||||
apiKeyForm: ApiKeyForm;
|
apiKeyForm: ApiKeyForm;
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
@@ -23,11 +25,14 @@ export function WorkspacePage(props: {
|
|||||||
message: string;
|
message: string;
|
||||||
section: WorkspaceSection;
|
section: WorkspaceSection;
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
|
taskQuery: WorkspaceTaskQuery;
|
||||||
|
taskTotal: number;
|
||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSectionChange: (value: WorkspaceSection) => void;
|
onSectionChange: (value: WorkspaceSection) => void;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||||
|
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +43,7 @@ export function WorkspacePage(props: {
|
|||||||
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
||||||
{props.section === 'billing' && <BillingPanel />}
|
{props.section === 'billing' && <BillingPanel />}
|
||||||
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
|
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
|
||||||
{props.section === 'tasks' && <TaskPanel data={props.data} />}
|
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,62 +301,319 @@ function ApiKeyPanel(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskPanel(props: { data: ConsoleData }) {
|
function TaskPanel(props: {
|
||||||
const tasks = useMemo(() => {
|
data: ConsoleData;
|
||||||
const latest = props.data.taskResult;
|
query: WorkspaceTaskQuery;
|
||||||
if (!latest) return props.data.tasks;
|
total: number;
|
||||||
return [latest, ...props.data.tasks.filter((item) => item.id !== latest.id)];
|
onQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||||
}, [props.data.taskResult, props.data.tasks]);
|
}) {
|
||||||
|
const [localMessage, setLocalMessage] = useState('');
|
||||||
|
const [jsonTask, setJsonTask] = useState<GatewayTask | null>(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 (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
<Card className="shTableViewportCard taskRecordViewport">
|
||||||
<CardTitle>任务记录</CardTitle>
|
<CardContent className="shTableViewportPanel">
|
||||||
</CardHeader>
|
{localMessage && <p className="formMessage">{localMessage}</p>}
|
||||||
<CardContent>
|
<TableViewportLayout>
|
||||||
{tasks.length ? (
|
<TableToolbar className="taskRecordFilters">
|
||||||
<div className="taskList">
|
<Label className="taskRecordSearchLabel">
|
||||||
{tasks.map((task) => (
|
搜索
|
||||||
<TaskRecord key={task.id} task={task} />
|
<span className="taskRecordSearchBox">
|
||||||
))}
|
<Search size={15} />
|
||||||
</div>
|
<Input
|
||||||
) : (
|
value={taskQuery.query}
|
||||||
<div className="emptyState">
|
placeholder="搜索 ID / RequestID / 模型 / API Key"
|
||||||
<strong>暂无任务</strong>
|
onChange={(event) => updateQuery(event.target.value)}
|
||||||
</div>
|
/>
|
||||||
)}
|
</span>
|
||||||
</CardContent>
|
</Label>
|
||||||
</Card>
|
<Label>
|
||||||
|
类型
|
||||||
|
<Select value={taskQuery.modelType || 'all'} onChange={(event) => updateTypeFilter(event.target.value)}>
|
||||||
|
<option value="all">全部类型</option>
|
||||||
|
{taskTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Label>
|
||||||
|
<Label className="taskRecordRangeLabel">
|
||||||
|
创建时间
|
||||||
|
<DateTimeRangePicker
|
||||||
|
from={taskQuery.createdFrom}
|
||||||
|
fromPlaceholder="开始日期"
|
||||||
|
to={taskQuery.createdTo}
|
||||||
|
toPlaceholder="结束日期"
|
||||||
|
onChange={updateCreatedRange}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</TableToolbar>
|
||||||
|
|
||||||
|
{tasks.length ? (
|
||||||
|
<Table className="shTableViewport taskRecordTable" density="compact">
|
||||||
|
<TableRow className="shTableHeader">
|
||||||
|
<TableHead>任务</TableHead>
|
||||||
|
<TableHead>RequestID</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead>Token</TableHead>
|
||||||
|
<TableHead>扣费</TableHead>
|
||||||
|
<TableHead>耗时</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>原始 JSON</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskRecord key={task.id} task={task} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="emptyState">
|
||||||
|
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
|
||||||
|
{hasActiveFilters && <span>调整关键词、类型或创建时间后再试。</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableFooter>
|
||||||
|
<div className="shTableFooterGroup">
|
||||||
|
<Label>
|
||||||
|
每页
|
||||||
|
<Select size="sm" value={String(taskQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>{option} 条</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Label>
|
||||||
|
<span>共 {props.total} 条 · {pageStart}-{pageEnd}</span>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="shTablePageJump"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submitPageJump();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>第 {currentPage} / {totalPages} 页</span>
|
||||||
|
<span>跳至</span>
|
||||||
|
<Input
|
||||||
|
aria-label="跳转页码"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
size="xs"
|
||||||
|
type="number"
|
||||||
|
value={pageJump}
|
||||||
|
onChange={(event) => setPageJump(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span>页</span>
|
||||||
|
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}>跳转</Button>
|
||||||
|
</form>
|
||||||
|
<TablePageActions>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
|
||||||
|
下一页
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</Button>
|
||||||
|
</TablePageActions>
|
||||||
|
</TableFooter>
|
||||||
|
</TableViewportLayout>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
ariaLabel="查看任务原始 JSON"
|
||||||
|
bodyClassName="taskJsonDialogBody"
|
||||||
|
className="taskJsonDialog"
|
||||||
|
footer={<Button type="button" size="sm" onClick={() => setJsonTask(null)}>关闭</Button>}
|
||||||
|
open={Boolean(jsonTask)}
|
||||||
|
title="任务原始 JSON"
|
||||||
|
onClose={() => setJsonTask(null)}
|
||||||
|
onSubmit={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<pre className="taskJsonPreview">{JSON.stringify(jsonTask, null, 2)}</pre>
|
||||||
|
</FormDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskRecord(props: { task: GatewayTask }) {
|
function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
|
||||||
const usage = props.task.usage ?? {};
|
const usage = props.task.usage ?? {};
|
||||||
const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-';
|
const tokenUsage = formatTokenUsage(usage);
|
||||||
const chargeText = props.task.finalChargeAmount !== undefined ? `${props.task.finalChargeAmount}` : '-';
|
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';
|
const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary';
|
||||||
return (
|
return (
|
||||||
<div className="taskPreview">
|
<TableRow>
|
||||||
<div className="taskRecordHeader">
|
<TableCell>
|
||||||
<Badge variant={badgeVariant}>{props.task.status}</Badge>
|
<span className="taskRecordPrimaryCell taskRecordIdentityCell">
|
||||||
<strong>{props.task.kind}</strong>
|
<strong>{props.task.kind}</strong>
|
||||||
<span>{props.task.model}</span>
|
<span className="taskRecordIdLine">
|
||||||
<span>{formatDateTime(props.task.createdAt)}</span>
|
<span>ID</span>
|
||||||
</div>
|
<code title={props.task.id}>{props.task.id}</code>
|
||||||
<div className="infoGrid compact">
|
</span>
|
||||||
<InfoItem label="API Key" value={props.task.apiKeyName || props.task.apiKeyId || '-'} />
|
</span>
|
||||||
<InfoItem label="RequestID" value={props.task.requestId || '-'} />
|
</TableCell>
|
||||||
<InfoItem label="模型类型" value={props.task.modelType || '-'} />
|
<TableCell>
|
||||||
<InfoItem label="实际模型" value={props.task.resolvedModel || props.task.model} />
|
<span className="taskRecordRequestLine">
|
||||||
<InfoItem label="Token" value={tokenText} />
|
<code title={props.task.requestId || '-'}>{props.task.requestId || '-'}</code>
|
||||||
<InfoItem label="扣费" value={chargeText} />
|
<Button
|
||||||
<InfoItem label="响应耗时" value={props.task.responseDurationMs ? `${props.task.responseDurationMs}ms` : '-'} />
|
type="button"
|
||||||
<InfoItem label="错误" value={props.task.errorCode || props.task.errorMessage || '-'} />
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
<pre>{JSON.stringify({ result: props.task.result, usage: props.task.usage, billings: props.task.billings, billingSummary: props.task.billingSummary, metrics: props.task.metrics }, null, 2)}</pre>
|
title={props.task.requestId ? '复制 RequestID' : '暂无 RequestID'}
|
||||||
</div>
|
disabled={!props.task.requestId}
|
||||||
|
onClick={() => void props.onCopyRequestId(props.task)}
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Badge variant={badgeVariant}>{props.task.status}</Badge></TableCell>
|
||||||
|
<TableCell className="taskRecordModelCell">
|
||||||
|
<span className="taskRecordPrimaryCell">
|
||||||
|
<strong>{resolvedModel}</strong>
|
||||||
|
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{props.task.modelType || '-'}</TableCell>
|
||||||
|
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
|
||||||
|
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
|
||||||
|
<TableCell>{chargeText}</TableCell>
|
||||||
|
<TableCell>{formatDuration(props.task.responseDurationMs)}</TableCell>
|
||||||
|
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
|
||||||
|
<Eye size={14} />
|
||||||
|
原始 JSON
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCellValue(value: unknown) {
|
||||||
|
if (value === undefined || value === null || value === '') return '-';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||||
|
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 (
|
||||||
|
<span className="taskRecordTokenUsage">
|
||||||
|
<span>输入:{formatCellValue(input)}/输出:{formatCellValue(output)}</span>
|
||||||
|
<span>总计:{formatCellValue(total)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function InfoItem(props: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="infoItem">
|
<div className="infoItem">
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function PlatformManagementPanel(props: {
|
|||||||
const text = [
|
const text = [
|
||||||
model.displayName,
|
model.displayName,
|
||||||
model.modelName,
|
model.modelName,
|
||||||
|
model.providerModelName,
|
||||||
model.modelAlias,
|
model.modelAlias,
|
||||||
...model.modelType,
|
...model.modelType,
|
||||||
model.provider,
|
model.provider,
|
||||||
@@ -482,9 +483,9 @@ function PlatformModelTable(props: {
|
|||||||
meta={[
|
meta={[
|
||||||
platform ? platformDisplayName(platform) : model.platformName ?? '-',
|
platform ? platformDisplayName(platform) : model.platformName ?? '-',
|
||||||
provider?.displayName ?? platform?.provider ?? model.provider ?? '-',
|
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}
|
title={model.displayName || model.modelName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -608,10 +609,13 @@ function ModelSelection(props: {
|
|||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
next.delete(modelId);
|
next.delete(modelId);
|
||||||
const modelDiscountFactors = { ...props.form.modelDiscountFactors };
|
const modelDiscountFactors = { ...props.form.modelDiscountFactors };
|
||||||
|
const modelNameMappings = { ...props.form.modelNameMappings };
|
||||||
delete modelDiscountFactors[modelId];
|
delete modelDiscountFactors[modelId];
|
||||||
|
delete modelNameMappings[modelId];
|
||||||
props.onChange({
|
props.onChange({
|
||||||
...props.form,
|
...props.form,
|
||||||
modelDiscountFactors,
|
modelDiscountFactors,
|
||||||
|
modelNameMappings,
|
||||||
selectionMode: 'partial',
|
selectionMode: 'partial',
|
||||||
selectedModelIds: Array.from(next),
|
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 (
|
return (
|
||||||
<div className="platformModelSelector spanTwo">
|
<div className="platformModelSelector spanTwo">
|
||||||
<div className="platformModelSelectorHeader">
|
<div className="platformModelSelectorHeader">
|
||||||
@@ -649,26 +663,44 @@ function ModelSelection(props: {
|
|||||||
<div className="platformModelEmpty">当前没有已选模型,点击“添加模型”从模型库选择。</div>
|
<div className="platformModelEmpty">当前没有已选模型,点击“添加模型”从模型库选择。</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="platformModelChoices">
|
<div className="platformModelChoices">
|
||||||
{selectedModels.map((model) => (
|
{selectedModels.map((model) => {
|
||||||
<div className="platformModelChoice" key={model.id}>
|
const modelLabel = stableModelAlias(model) || model.providerModelName;
|
||||||
<div className="platformModelChoiceMain">
|
const providerModelName = props.form.modelNameMappings[model.id] ?? model.providerModelName;
|
||||||
<span>
|
return (
|
||||||
<strong>{stableModelAlias(model) || model.providerModelName}</strong>
|
<div className="platformModelChoice" key={model.id}>
|
||||||
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
|
<div className="platformModelChoiceMain">
|
||||||
</span>
|
<span>
|
||||||
|
<strong>{modelLabel}</strong>
|
||||||
|
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="platformModelChoiceFields">
|
||||||
|
<Label>
|
||||||
|
调用模型名
|
||||||
|
<Input
|
||||||
|
aria-label={`${modelLabel} 调用模型名`}
|
||||||
|
placeholder={model.providerModelName}
|
||||||
|
value={providerModelName}
|
||||||
|
onChange={(event) => updateModelNameMapping(model.id, event.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label>
|
||||||
|
折扣率
|
||||||
|
<Input
|
||||||
|
aria-label={`${modelLabel} 折扣率`}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="继承"
|
||||||
|
value={props.form.modelDiscountFactors[model.id] ?? ''}
|
||||||
|
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
);
|
||||||
aria-label={`${stableModelAlias(model) || model.providerModelName} 折扣率`}
|
})}
|
||||||
inputMode="decimal"
|
|
||||||
placeholder="折扣率"
|
|
||||||
value={props.form.modelDiscountFactors[model.id] ?? ''}
|
|
||||||
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ModelPickerDialog
|
<ModelPickerDialog
|
||||||
@@ -864,6 +896,7 @@ function platformToForm(
|
|||||||
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
||||||
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
||||||
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
|
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
|
||||||
|
modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels),
|
||||||
selectionMode: 'partial',
|
selectionMode: 'partial',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -885,10 +918,19 @@ function platformModelDiscountFactors(platform: IntegrationPlatform, baseModels:
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function platformModelNameMappings(platform: IntegrationPlatform, baseModels: BaseModelCatalogItem[], platformModels: PlatformModel[]) {
|
||||||
|
return platformModels.reduce<Record<string, string>>((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) {
|
function findBaseModelForPlatformModel(platform: IntegrationPlatform | undefined, baseModels: BaseModelCatalogItem[], model: PlatformModel) {
|
||||||
return baseModels.find((item) => item.id === model.baseModelId) ??
|
return baseModels.find((item) => item.id === model.baseModelId) ??
|
||||||
baseModels.find((item) => item.canonicalModelKey === model.modelAlias) ??
|
baseModels.find((item) => item.canonicalModelKey === model.modelAlias) ??
|
||||||
baseModels.find((item) => stableModelAlias(item) === 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)));
|
baseModels.find((item) => item.providerKey === platform?.provider && item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface PlatformWizardForm {
|
|||||||
supportUrlInput: boolean;
|
supportUrlInput: boolean;
|
||||||
modelDiscountFactor: string;
|
modelDiscountFactor: string;
|
||||||
modelDiscountFactors: Record<string, string>;
|
modelDiscountFactors: Record<string, string>;
|
||||||
|
modelNameMappings: Record<string, string>;
|
||||||
modelOverrideRetry: boolean;
|
modelOverrideRetry: boolean;
|
||||||
modelRetryEnabled: boolean;
|
modelRetryEnabled: boolean;
|
||||||
modelRetryMaxAttempts: string;
|
modelRetryMaxAttempts: string;
|
||||||
@@ -78,6 +79,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
|||||||
supportUrlInput: true,
|
supportUrlInput: true,
|
||||||
modelDiscountFactor: '',
|
modelDiscountFactor: '',
|
||||||
modelDiscountFactors: {},
|
modelDiscountFactors: {},
|
||||||
|
modelNameMappings: {},
|
||||||
modelOverrideRetry: false,
|
modelOverrideRetry: false,
|
||||||
modelRetryEnabled: true,
|
modelRetryEnabled: true,
|
||||||
modelRetryMaxAttempts: '2',
|
modelRetryMaxAttempts: '2',
|
||||||
@@ -99,6 +101,7 @@ export function applyProviderDefaults(form: PlatformWizardForm, provider: string
|
|||||||
authType: defaults?.defaultAuthType ?? 'APIKey',
|
authType: defaults?.defaultAuthType ?? 'APIKey',
|
||||||
selectedModelIds: [],
|
selectedModelIds: [],
|
||||||
modelDiscountFactors: {},
|
modelDiscountFactors: {},
|
||||||
|
modelNameMappings: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +151,7 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
|
|||||||
baseModelId: model.id,
|
baseModelId: model.id,
|
||||||
canonicalModelKey: model.canonicalModelKey,
|
canonicalModelKey: model.canonicalModelKey,
|
||||||
modelName: model.providerModelName,
|
modelName: model.providerModelName,
|
||||||
|
providerModelName: optionalString(form.modelNameMappings[model.id]) ?? model.providerModelName,
|
||||||
modelAlias: stableModelAlias(model),
|
modelAlias: stableModelAlias(model),
|
||||||
modelType: baseModelTypes(model),
|
modelType: baseModelTypes(model),
|
||||||
displayName: stableModelAlias(model) || model.providerModelName,
|
displayName: stableModelAlias(model) || model.providerModelName,
|
||||||
@@ -329,8 +333,8 @@ function limitRule(metric: string, value: string, windowSeconds = 60) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalString(value: string) {
|
function optionalString(value: string | null | undefined) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value?.trim() ?? '';
|
||||||
return trimmed || undefined;
|
return trimmed || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-8
@@ -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 {
|
export interface AppRouteState {
|
||||||
activePage: PageKey;
|
activePage: PageKey;
|
||||||
@@ -6,6 +6,7 @@ export interface AppRouteState {
|
|||||||
apiDocSection: ApiDocSection;
|
apiDocSection: ApiDocSection;
|
||||||
playgroundMode: PlaygroundMode;
|
playgroundMode: PlaygroundMode;
|
||||||
workspaceSection: WorkspaceSection;
|
workspaceSection: WorkspaceSection;
|
||||||
|
workspaceTaskQuery: WorkspaceTaskQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultRouteState: AppRouteState = {
|
export const defaultRouteState: AppRouteState = {
|
||||||
@@ -14,6 +15,7 @@ export const defaultRouteState: AppRouteState = {
|
|||||||
apiDocSection: 'chat',
|
apiDocSection: 'chat',
|
||||||
playgroundMode: 'chat',
|
playgroundMode: 'chat',
|
||||||
workspaceSection: 'overview',
|
workspaceSection: 'overview',
|
||||||
|
workspaceTaskQuery: defaultWorkspaceTaskQuery(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const workspacePaths: Record<WorkspaceSection, string> = {
|
const workspacePaths: Record<WorkspaceSection, string> = {
|
||||||
@@ -55,21 +57,23 @@ const adminSections = reverseMap(adminPaths);
|
|||||||
const docsSections = reverseMap(docsPaths);
|
const docsSections = reverseMap(docsPaths);
|
||||||
const playgroundSections = reverseMap(playgroundPaths);
|
const playgroundSections = reverseMap(playgroundPaths);
|
||||||
|
|
||||||
export function parseAppRoute(pathname = window.location.pathname): AppRouteState {
|
export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState {
|
||||||
const path = normalizePath(pathname);
|
const url = new URL(input, window.location.origin);
|
||||||
|
const path = normalizePath(url.pathname);
|
||||||
|
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
|
||||||
if (path === '/') return { ...defaultRouteState };
|
if (path === '/') return { ...defaultRouteState };
|
||||||
if (path.startsWith('/playground')) {
|
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')) {
|
if (path.startsWith('/workspace')) {
|
||||||
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path) };
|
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path), workspaceTaskQuery };
|
||||||
}
|
}
|
||||||
if (path.startsWith('/admin')) {
|
if (path.startsWith('/admin')) {
|
||||||
return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path) };
|
return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path), workspaceTaskQuery };
|
||||||
}
|
}
|
||||||
if (path.startsWith('/docs')) {
|
if (path.startsWith('/docs')) {
|
||||||
return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path) };
|
return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path), workspaceTaskQuery };
|
||||||
}
|
}
|
||||||
return { ...defaultRouteState };
|
return { ...defaultRouteState };
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,19 @@ export function pathForWorkspaceSection(section: WorkspaceSection) {
|
|||||||
return workspacePaths[section] ?? workspacePaths.overview;
|
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) {
|
export function pathForAdminSection(section: AdminSection) {
|
||||||
return adminPaths[section] ?? adminPaths.overview;
|
return adminPaths[section] ?? adminPaths.overview;
|
||||||
}
|
}
|
||||||
@@ -131,3 +148,50 @@ function normalizePath(pathname: string) {
|
|||||||
function reverseMap<T extends string>(value: Record<T, string>) {
|
function reverseMap<T extends string>(value: Record<T, string>) {
|
||||||
return Object.fromEntries(Object.entries(value).map(([key, path]) => [path, key])) as Record<string, T>;
|
return Object.fromEntries(Object.entries(value).map(([key, path]) => [path, key])) as Record<string, T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|||||||
+175
-21
@@ -34,14 +34,14 @@
|
|||||||
--color-text-strong: var(--text-strong);
|
--color-text-strong: var(--text-strong);
|
||||||
--color-text-normal: var(--text-normal);
|
--color-text-normal: var(--text-normal);
|
||||||
--color-text-soft: var(--text-soft);
|
--color-text-soft: var(--text-soft);
|
||||||
--text-xs: 12px;
|
--text-xs: 0.75rem;
|
||||||
--text-sm: 13px;
|
--text-sm: 0.8125rem;
|
||||||
--text-base: 14px;
|
--text-base: 0.875rem;
|
||||||
--text-md: 15px;
|
--text-md: 0.9375rem;
|
||||||
--text-lg: 16px;
|
--text-lg: 1rem;
|
||||||
--text-xl: 18px;
|
--text-xl: 1.125rem;
|
||||||
--text-2xl: 22px;
|
--text-2xl: 1.375rem;
|
||||||
--text-3xl: 30px;
|
--text-3xl: 1.875rem;
|
||||||
--radius-sm: 7px;
|
--radius-sm: 7px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
@@ -144,7 +144,7 @@ p {
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 28px;
|
font-size: 1.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ strong {
|
|||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -224,21 +224,162 @@ strong {
|
|||||||
gap: 14px;
|
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;
|
display: grid;
|
||||||
gap: 14px;
|
grid-template-columns: 18px minmax(0, 1fr);
|
||||||
}
|
|
||||||
|
|
||||||
.taskRecordHeader {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px 12px;
|
gap: 8px;
|
||||||
color: var(--muted-foreground);
|
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);
|
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 {
|
.appShell {
|
||||||
@@ -465,7 +606,7 @@ strong {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 24px;
|
font-size: 1.5rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
@@ -526,4 +667,17 @@ strong {
|
|||||||
.tokenInline input {
|
.tokenInline input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.taskRecordFilters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTableFooter {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shTablePageActions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
.docsBrand {
|
.docsBrand {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
font-size: 17px;
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docsSearch {
|
.docsSearch {
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
.docsGroup h3 {
|
.docsGroup h3 {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docsGroup button {
|
.docsGroup button {
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
padding: 13px 16px;
|
padding: 13px 16px;
|
||||||
border-bottom: 1px solid #f0f2f5;
|
border-bottom: 1px solid #f0f2f5;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paramRow em {
|
.paramRow em {
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f7f8fa;
|
background: #f7f8fa;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.releaseNotice span {
|
.releaseNotice span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landingHero {
|
.landingHero {
|
||||||
@@ -43,14 +43,14 @@
|
|||||||
|
|
||||||
.landingCopy h1 {
|
.landingCopy h1 {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
font-size: 48px;
|
font-size: 3rem;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landingCopy p {
|
.landingCopy p {
|
||||||
max-width: 660px;
|
max-width: 660px;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +102,12 @@
|
|||||||
|
|
||||||
.previewGrid span {
|
.previewGrid span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewGrid strong {
|
.previewGrid strong {
|
||||||
font-size: 15px;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewFlow {
|
.previewFlow {
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.landingSectionHeader h2 {
|
.landingSectionHeader h2 {
|
||||||
font-size: 22px;
|
font-size: 1.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coverageGrid,
|
.coverageGrid,
|
||||||
@@ -161,18 +161,18 @@
|
|||||||
.landingFeatureCard span,
|
.landingFeatureCard span,
|
||||||
.advantageCard p {
|
.advantageCard p {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landingFeatureCard strong {
|
.landingFeatureCard strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
font-size: 18px;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advantageCard strong {
|
.advantageCard strong {
|
||||||
font-size: 17px;
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginRequiredPage {
|
.loginRequiredPage {
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.landingCopy h1 {
|
.landingCopy h1 {
|
||||||
font-size: 34px;
|
font-size: 2.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coverageGrid,
|
.coverageGrid,
|
||||||
|
|||||||
+243
-73
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
.filterGroup h3 {
|
.filterGroup h3 {
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterChips {
|
.filterChips {
|
||||||
@@ -57,22 +57,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filterChip {
|
.filterChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
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"] {
|
.filterChip[data-active="true"] {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #fff;
|
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 {
|
.modelsContent {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -143,65 +182,173 @@
|
|||||||
.modelCards {
|
.modelCards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(260px, 1fr));
|
grid-template-columns: repeat(3, minmax(260px, 1fr));
|
||||||
gap: 14px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelCard .shCardContent {
|
.modelCard .shCardContent {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 150px;
|
align-content: start;
|
||||||
gap: 13px;
|
min-height: 265px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelCardTop {
|
.modelCardTop {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
gap: 11px;
|
gap: 10px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelCardTop strong,
|
.modelCardTop .modelIcon {
|
||||||
.modelCardTop span,
|
width: 38px;
|
||||||
.modelCard p {
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardTop .modelIconImage {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardHeaderText {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCardTop strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelCardTop span,
|
.modelCardIntro {
|
||||||
.modelCard p,
|
display: grid;
|
||||||
.modelCardFooter span {
|
align-content: start;
|
||||||
color: var(--muted-foreground);
|
gap: 7px;
|
||||||
font-size: 12px;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.modelTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelTags span {
|
.modelTags span {
|
||||||
padding: 4px 7px;
|
flex: 0 0 auto;
|
||||||
border: 1px solid #eceff3;
|
padding: 2px 6px;
|
||||||
border-radius: 999px;
|
border: 1px solid rgba(37, 99, 235, 0.32);
|
||||||
background: var(--surface-subtle);
|
border-radius: 6px;
|
||||||
color: var(--text-soft);
|
background: rgba(37, 99, 235, 0.06);
|
||||||
font-size: 12px;
|
color: #1d4ed8;
|
||||||
}
|
|
||||||
|
|
||||||
.modelCardFooter {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modelCardFooter a {
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: var(--font-weight-semibold);
|
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 {
|
.providerToolbar {
|
||||||
@@ -214,7 +361,7 @@
|
|||||||
.providerToolbar p {
|
.providerToolbar p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inlineActions {
|
.inlineActions {
|
||||||
@@ -511,7 +658,7 @@
|
|||||||
.formMessage {
|
.formMessage {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +716,7 @@
|
|||||||
.providerCatalogBody span,
|
.providerCatalogBody span,
|
||||||
.providerCatalogMeta {
|
.providerCatalogMeta {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.providerCatalogMeta,
|
.providerCatalogMeta,
|
||||||
@@ -625,7 +772,7 @@
|
|||||||
.baseModelCardBody > div:first-child span {
|
.baseModelCardBody > div:first-child span {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.baseModelCard .providerCatalogActions {
|
.baseModelCard .providerCatalogActions {
|
||||||
@@ -644,7 +791,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +827,7 @@
|
|||||||
.baseModelForm textarea {
|
.baseModelForm textarea {
|
||||||
min-height: 124px;
|
min-height: 124px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformGrid {
|
.platformGrid {
|
||||||
@@ -716,7 +863,7 @@
|
|||||||
.platformCardBody > div:first-child span,
|
.platformCardBody > div:first-child span,
|
||||||
.platformCardBody p {
|
.platformCardBody p {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformCardBody p {
|
.platformCardBody p {
|
||||||
@@ -833,7 +980,7 @@
|
|||||||
|
|
||||||
.platformEmptyState strong {
|
.platformEmptyState strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 18px;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformEmptyState span {
|
.platformEmptyState span {
|
||||||
@@ -872,7 +1019,7 @@
|
|||||||
.platformModelRow span {
|
.platformModelRow span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -942,12 +1089,12 @@
|
|||||||
|
|
||||||
.platformToggle strong {
|
.platformToggle strong {
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformToggle small {
|
.platformToggle small {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformReadonlyField {
|
.platformReadonlyField {
|
||||||
@@ -955,7 +1102,7 @@
|
|||||||
place-items: center start;
|
place-items: center start;
|
||||||
padding: 0 11px;
|
padding: 0 11px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,7 +1122,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -983,7 +1130,7 @@
|
|||||||
|
|
||||||
.platformCredentialField small {
|
.platformCredentialField small {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-normal);
|
font-weight: var(--font-weight-normal);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -1022,12 +1169,12 @@
|
|||||||
|
|
||||||
.platformModelSelectorHeader strong {
|
.platformModelSelectorHeader strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelSelectorHeader span {
|
.platformModelSelectorHeader span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformSegmented {
|
.platformSegmented {
|
||||||
@@ -1059,8 +1206,8 @@
|
|||||||
.platformModelChoices {
|
.platformModelChoices {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 0.625rem;
|
||||||
max-height: 310px;
|
max-height: 19.375rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,39 +1217,54 @@
|
|||||||
|
|
||||||
.platformModelChoice {
|
.platformModelChoice {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 112px auto;
|
grid-template-columns: minmax(10rem, 1fr) minmax(14rem, 1.4fr) auto;
|
||||||
gap: 10px;
|
gap: 0.625rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 8px;
|
border-radius: 0.5rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelChoiceMain {
|
.platformModelChoiceMain {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 0.625rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelChoice input {
|
.platformModelChoice input {
|
||||||
margin-top: 3px;
|
margin-top: 0.1875rem;
|
||||||
accent-color: var(--text-strong);
|
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;
|
margin-top: 0;
|
||||||
min-height: 34px;
|
min-height: 2rem;
|
||||||
padding: 0 9px;
|
padding: 0 0.5625rem;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelChoiceMain span {
|
.platformModelChoiceMain span {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 4px;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelChoiceMain strong,
|
.platformModelChoiceMain strong,
|
||||||
@@ -1115,7 +1277,7 @@
|
|||||||
.platformModelChoiceMain small,
|
.platformModelChoiceMain small,
|
||||||
.platformModelEmpty {
|
.platformModelEmpty {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platformModelEmpty {
|
.platformModelEmpty {
|
||||||
@@ -1164,12 +1326,12 @@
|
|||||||
|
|
||||||
.modelPickerHeader strong {
|
.modelPickerHeader strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 15px;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelPickerHeader span {
|
.modelPickerHeader span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelPickerToolbar {
|
.modelPickerToolbar {
|
||||||
@@ -1242,12 +1404,12 @@
|
|||||||
|
|
||||||
.modelPickerItem strong {
|
.modelPickerItem strong {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelPickerItem small {
|
.modelPickerItem small {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelPickerActions {
|
.modelPickerActions {
|
||||||
@@ -1295,7 +1457,7 @@
|
|||||||
.runtimePolicyCard header span,
|
.runtimePolicyCard header span,
|
||||||
.runtimePolicyCard p {
|
.runtimePolicyCard p {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtimePolicySummary {
|
.runtimePolicySummary {
|
||||||
@@ -1312,7 +1474,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1348,7 +1510,7 @@
|
|||||||
|
|
||||||
.runtimePolicySection header span {
|
.runtimePolicySection header span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtimePolicyRows {
|
.runtimePolicyRows {
|
||||||
@@ -1401,6 +1563,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platformModelChoice {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platformModelChoiceFields {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.providerCatalogCard {
|
.providerCatalogCard {
|
||||||
grid-template-columns: 48px minmax(0, 1fr);
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1166,7 +1166,7 @@
|
|||||||
|
|
||||||
.mediaTaskTimeline h1 {
|
.mediaTaskTimeline h1 {
|
||||||
width: min(1240px, 100%);
|
width: min(1240px, 100%);
|
||||||
font-size: 30px;
|
font-size: 1.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mediaTaskTimeline > .playgroundError {
|
.mediaTaskTimeline > .playgroundError {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.pricingRuleCard p {
|
.pricingRuleCard p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingRuleItems {
|
.pricingRuleItems {
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingRuleItems strong {
|
.pricingRuleItems strong {
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingRuleItems svg {
|
.pricingRuleItems svg {
|
||||||
@@ -96,13 +96,13 @@
|
|||||||
.pricingModeRule strong,
|
.pricingModeRule strong,
|
||||||
.pricingWeightTable strong {
|
.pricingWeightTable strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingModeHeader span,
|
.pricingModeHeader span,
|
||||||
.pricingModeRule header span {
|
.pricingModeRule header span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingModeTabs {
|
.pricingModeTabs {
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
border-bottom: 3px solid transparent;
|
border-bottom: 3px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricingFormulaBox {
|
.pricingFormulaBox {
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
.pricingFormulaBox p {
|
.pricingFormulaBox p {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-size: 13px;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+275
-1
@@ -671,10 +671,250 @@
|
|||||||
background: var(--surface);
|
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 {
|
.shTable {
|
||||||
overflow: auto;
|
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 {
|
.shTableRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||||
@@ -706,6 +946,39 @@
|
|||||||
font-size: var(--font-size-sm);
|
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 {
|
.emptyState {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 104px;
|
min-height: 104px;
|
||||||
@@ -726,7 +999,7 @@
|
|||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
font-family: "SFMono-Regular", Consolas, monospace;
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modelMeta code,
|
.modelMeta code,
|
||||||
@@ -750,4 +1023,5 @@
|
|||||||
.formDialogBody {
|
.formDialogBody {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ export interface ApiKeyForm {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTaskQuery {
|
||||||
|
query: string;
|
||||||
|
modelType: string;
|
||||||
|
createdFrom: string;
|
||||||
|
createdTo: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformForm {
|
export interface PlatformForm {
|
||||||
provider: string;
|
provider: string;
|
||||||
platformKey: string;
|
platformKey: string;
|
||||||
@@ -56,6 +65,7 @@ export interface PlatformModelForm {
|
|||||||
platformId: string;
|
platformId: string;
|
||||||
canonicalModelKey: string;
|
canonicalModelKey: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
providerModelName: string;
|
||||||
modelAlias: string;
|
modelAlias: string;
|
||||||
modelType: string[];
|
modelType: string[];
|
||||||
pricingRuleSetId: string;
|
pricingRuleSetId: string;
|
||||||
@@ -83,6 +93,7 @@ export interface PlatformModelBindingInput {
|
|||||||
canonicalModelKey?: string;
|
canonicalModelKey?: string;
|
||||||
baseModelId?: string;
|
baseModelId?: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
providerModelName?: string;
|
||||||
modelAlias?: string;
|
modelAlias?: string;
|
||||||
modelType: string[];
|
modelType: string[];
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ export interface PlatformModel {
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
platformName?: string;
|
platformName?: string;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
providerModelName?: string;
|
||||||
modelAlias?: string;
|
modelAlias?: string;
|
||||||
modelType: string[];
|
modelType: string[];
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -579,6 +580,93 @@ export interface PlatformModel {
|
|||||||
updatedAt: string;
|
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 {
|
export interface RateLimitWindow {
|
||||||
scopeType: string;
|
scopeType: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
@@ -647,4 +735,7 @@ export interface GatewayTaskEvent {
|
|||||||
|
|
||||||
export interface ListResponse<T> {
|
export interface ListResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+885
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user