feat(default): add real rankings data
This commit is contained in:
parent
0f9f094a48
commit
f8cf9c57c4
24
controller/rankings.go
Normal file
24
controller/rankings.go
Normal file
@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetRankings(c *gin.Context) {
|
||||
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
@ -242,7 +242,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
}
|
||||
if newAPIError != nil {
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, false)
|
||||
perfmetrics.RecordRelaySample(relayInfo, false, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,11 +13,13 @@ type PerfMetric struct {
|
||||
ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"`
|
||||
Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
|
||||
BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
|
||||
RequestCount int64 `json:"request_count" gorm:"default:0"`
|
||||
SuccessCount int64 `json:"success_count" gorm:"default:0"`
|
||||
TotalLatencyMs int64 `json:"total_latency_ms" gorm:"default:0"`
|
||||
TtftSumMs int64 `json:"ttft_sum_ms" gorm:"default:0"`
|
||||
TtftCount int64 `json:"ttft_count" gorm:"default:0"`
|
||||
RequestCount int64 `json:"-" gorm:"default:0"`
|
||||
SuccessCount int64 `json:"-" gorm:"default:0"`
|
||||
TotalLatencyMs int64 `json:"-" gorm:"default:0"`
|
||||
TtftSumMs int64 `json:"-" gorm:"default:0"`
|
||||
TtftCount int64 `json:"-" gorm:"default:0"`
|
||||
OutputTokens int64 `json:"-" gorm:"default:0"`
|
||||
GenerationMs int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
func (PerfMetric) TableName() string {
|
||||
@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
|
||||
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
|
||||
}),
|
||||
}).Create(metric).Error
|
||||
}
|
||||
|
||||
66
model/usedata_rankings.go
Normal file
66
model/usedata_rankings.go
Normal file
@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RankingQuotaTotal struct {
|
||||
ModelName string `json:"model_name"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type RankingQuotaBucket struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Bucket int64 `json:"bucket"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
func GetRankingQuotaTotals(startTime int64, endTime int64) ([]RankingQuotaTotal, error) {
|
||||
var rows []RankingQuotaTotal
|
||||
query := DB.Table("quota_data").
|
||||
Select("model_name, sum(token_used) as total_tokens").
|
||||
Where("model_name <> ''").
|
||||
Group("model_name").
|
||||
Having("sum(token_used) > 0").
|
||||
Order("total_tokens DESC")
|
||||
query = applyRankingQuotaTimeRange(query, startTime, endTime)
|
||||
err := query.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func GetRankingQuotaBuckets(startTime int64, endTime int64, bucketSize int64) ([]RankingQuotaBucket, error) {
|
||||
if bucketSize <= 0 {
|
||||
bucketSize = 3600
|
||||
}
|
||||
bucketExpr := rankingBucketExpr(bucketSize)
|
||||
var rows []RankingQuotaBucket
|
||||
query := DB.Table("quota_data").
|
||||
Select(fmt.Sprintf("model_name, %s as bucket, sum(token_used) as tokens", bucketExpr)).
|
||||
Where("model_name <> ''").
|
||||
Group(fmt.Sprintf("model_name, %s", bucketExpr)).
|
||||
Having("sum(token_used) > 0").
|
||||
Order("bucket ASC")
|
||||
query = applyRankingQuotaTimeRange(query, startTime, endTime)
|
||||
err := query.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func rankingBucketExpr(bucketSize int64) string {
|
||||
if common.UsingMySQL {
|
||||
return fmt.Sprintf("FLOOR(created_at / %d) * %d", bucketSize, bucketSize)
|
||||
}
|
||||
return fmt.Sprintf("(created_at / %d) * %d", bucketSize, bucketSize)
|
||||
}
|
||||
|
||||
func applyRankingQuotaTimeRange(query *gorm.DB, startTime int64, endTime int64) *gorm.DB {
|
||||
if startTime > 0 {
|
||||
query = query.Where("created_at >= ?", startTime)
|
||||
}
|
||||
if endTime > 0 {
|
||||
query = query.Where("created_at <= ?", endTime)
|
||||
}
|
||||
return query
|
||||
}
|
||||
@ -47,6 +47,8 @@ func flushCompletedBuckets() {
|
||||
TotalLatencyMs: drained.totalLatencyMs,
|
||||
TtftSumMs: drained.ttftSumMs,
|
||||
TtftCount: drained.ttftCount,
|
||||
OutputTokens: drained.outputTokens,
|
||||
GenerationMs: drained.generationMs,
|
||||
})
|
||||
if err != nil {
|
||||
bucket.addCounters(drained)
|
||||
@ -82,6 +84,8 @@ func redisCounters(values map[string]string) counters {
|
||||
totalLatencyMs: parseRedisInt(values["lat"]),
|
||||
ttftSumMs: parseRedisInt(values["ttft"]),
|
||||
ttftCount: parseRedisInt(values["ttft_n"]),
|
||||
outputTokens: parseRedisInt(values["out"]),
|
||||
generationMs: parseRedisInt(values["gen_ms"]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,13 +15,15 @@ import (
|
||||
|
||||
var hotBuckets sync.Map
|
||||
|
||||
// seriesSchema is a stable client cache/schema marker. Do not change it when
|
||||
// hiding fields or making response-only privacy hardening changes.
|
||||
const seriesSchema = "dbcd0a3c01b55203"
|
||||
|
||||
func Init() {
|
||||
go flushLoop()
|
||||
}
|
||||
|
||||
func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
|
||||
func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
|
||||
if hasTtft {
|
||||
ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds()
|
||||
}
|
||||
latencyMs := now.Sub(info.StartTime).Milliseconds()
|
||||
generationMs := latencyMs
|
||||
if hasTtft {
|
||||
generationMs = now.Sub(info.FirstResponseTime).Milliseconds()
|
||||
}
|
||||
if generationMs <= 0 {
|
||||
generationMs = latencyMs
|
||||
}
|
||||
Record(Sample{
|
||||
Model: info.OriginModelName,
|
||||
Group: info.UsingGroup,
|
||||
LatencyMs: now.Sub(info.StartTime).Milliseconds(),
|
||||
TtftMs: ttftMs,
|
||||
HasTtft: hasTtft,
|
||||
Success: success,
|
||||
Model: info.OriginModelName,
|
||||
Group: info.UsingGroup,
|
||||
LatencyMs: latencyMs,
|
||||
TtftMs: ttftMs,
|
||||
HasTtft: hasTtft,
|
||||
Success: success,
|
||||
OutputTokens: outputTokens,
|
||||
GenerationMs: generationMs,
|
||||
})
|
||||
}
|
||||
|
||||
@ -89,6 +101,8 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
totalLatencyMs: row.TotalLatencyMs,
|
||||
ttftSumMs: row.TtftSumMs,
|
||||
ttftCount: row.TtftCount,
|
||||
outputTokens: row.OutputTokens,
|
||||
generationMs: row.GenerationMs,
|
||||
})
|
||||
}
|
||||
|
||||
@ -125,6 +139,8 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters)
|
||||
current.totalLatencyMs += value.totalLatencyMs
|
||||
current.ttftSumMs += value.ttftSumMs
|
||||
current.ttftCount += value.ttftCount
|
||||
current.outputTokens += value.outputTokens
|
||||
current.generationMs += value.generationMs
|
||||
merged[key] = current
|
||||
}
|
||||
|
||||
@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
total.totalLatencyMs += value.totalLatencyMs
|
||||
total.ttftSumMs += value.ttftSumMs
|
||||
total.ttftCount += value.ttftCount
|
||||
total.outputTokens += value.outputTokens
|
||||
total.generationMs += value.generationMs
|
||||
series = append(series, bucketPoint(ts, value))
|
||||
}
|
||||
|
||||
@ -174,9 +192,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
|
||||
AvgTtftMs: avg(total.ttftSumMs, total.ttftCount),
|
||||
AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
|
||||
SuccessRate: successRate(total),
|
||||
RequestCount: total.requestCount,
|
||||
SuccessCount: total.successCount,
|
||||
TtftCount: total.ttftCount,
|
||||
AvgTps: avgTps(total),
|
||||
Series: series,
|
||||
})
|
||||
}
|
||||
@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint {
|
||||
AvgTtftMs: avg(value.ttftSumMs, value.ttftCount),
|
||||
AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
|
||||
SuccessRate: successRate(value),
|
||||
Count: value.requestCount,
|
||||
SuccessCount: value.successCount,
|
||||
TtftCount: value.ttftCount,
|
||||
AvgTps: avgTps(value),
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,6 +228,13 @@ func successRate(value counters) float64 {
|
||||
return float64(value.successCount) / float64(value.requestCount) * 100
|
||||
}
|
||||
|
||||
func avgTps(value counters) float64 {
|
||||
if value.outputTokens <= 0 || value.generationMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(value.outputTokens) / (float64(value.generationMs) / 1000)
|
||||
}
|
||||
|
||||
func recordRedis(key bucketKey, sample Sample) {
|
||||
if !common.RedisEnabled || common.RDB == nil {
|
||||
return
|
||||
@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) {
|
||||
pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
|
||||
pipe.HIncrBy(ctx, redisKey, "ttft_n", 1)
|
||||
}
|
||||
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
|
||||
pipe.HIncrBy(ctx, redisKey, "out", sample.OutputTokens)
|
||||
pipe.HIncrBy(ctx, redisKey, "gen_ms", sample.GenerationMs)
|
||||
}
|
||||
pipe.Expire(ctx, redisKey, time.Hour)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
@ -8,12 +8,14 @@ type Store interface {
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
Model string
|
||||
Group string
|
||||
LatencyMs int64
|
||||
TtftMs int64
|
||||
HasTtft bool
|
||||
Success bool
|
||||
Model string
|
||||
Group string
|
||||
LatencyMs int64
|
||||
TtftMs int64
|
||||
HasTtft bool
|
||||
Success bool
|
||||
OutputTokens int64
|
||||
GenerationMs int64
|
||||
}
|
||||
|
||||
type QueryParams struct {
|
||||
@ -27,9 +29,7 @@ type BucketPoint struct {
|
||||
AvgTtftMs int64 `json:"avg_ttft_ms"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
Count int64 `json:"count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TtftCount int64 `json:"ttft_count"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
}
|
||||
|
||||
type GroupResult struct {
|
||||
@ -37,9 +37,7 @@ type GroupResult struct {
|
||||
AvgTtftMs int64 `json:"avg_ttft_ms"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TtftCount int64 `json:"ttft_count"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
Series []BucketPoint `json:"series"`
|
||||
}
|
||||
|
||||
@ -61,6 +59,8 @@ type counters struct {
|
||||
totalLatencyMs int64
|
||||
ttftSumMs int64
|
||||
ttftCount int64
|
||||
outputTokens int64
|
||||
generationMs int64
|
||||
}
|
||||
|
||||
type atomicBucket struct {
|
||||
@ -69,6 +69,8 @@ type atomicBucket struct {
|
||||
totalLatencyMs atomic.Int64
|
||||
ttftSumMs atomic.Int64
|
||||
ttftCount atomic.Int64
|
||||
outputTokens atomic.Int64
|
||||
generationMs atomic.Int64
|
||||
}
|
||||
|
||||
func (b *atomicBucket) add(sample Sample) {
|
||||
@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) {
|
||||
b.ttftSumMs.Add(sample.TtftMs)
|
||||
b.ttftCount.Add(1)
|
||||
}
|
||||
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
|
||||
b.outputTokens.Add(sample.OutputTokens)
|
||||
b.generationMs.Add(sample.GenerationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *atomicBucket) snapshot() counters {
|
||||
@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters {
|
||||
totalLatencyMs: b.totalLatencyMs.Load(),
|
||||
ttftSumMs: b.ttftSumMs.Load(),
|
||||
ttftCount: b.ttftCount.Load(),
|
||||
outputTokens: b.outputTokens.Load(),
|
||||
generationMs: b.generationMs.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,6 +110,8 @@ func (b *atomicBucket) drain() counters {
|
||||
totalLatencyMs: b.totalLatencyMs.Swap(0),
|
||||
ttftSumMs: b.ttftSumMs.Swap(0),
|
||||
ttftCount: b.ttftCount.Swap(0),
|
||||
outputTokens: b.outputTokens.Swap(0),
|
||||
generationMs: b.generationMs.Swap(0),
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,4 +131,10 @@ func (b *atomicBucket) addCounters(c counters) {
|
||||
if c.ttftCount != 0 {
|
||||
b.ttftCount.Add(c.ttftCount)
|
||||
}
|
||||
if c.outputTokens != 0 {
|
||||
b.outputTokens.Add(c.outputTokens)
|
||||
}
|
||||
if c.generationMs != 0 {
|
||||
b.generationMs.Add(c.generationMs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics)
|
||||
apiRouter.GET("/rankings", controller.GetRankings)
|
||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
||||
|
||||
@ -377,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
|
||||
Other: other,
|
||||
})
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, true)
|
||||
perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
599
service/rankings.go
Normal file
599
service/rankings.go
Normal file
@ -0,0 +1,599 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
const (
|
||||
rankingCacheTTL = 5 * time.Minute
|
||||
rankingLeaderboardLimit = 20
|
||||
rankingHistoryLimit = 10
|
||||
rankingVendorLimit = 5
|
||||
rankingMoverLimit = 6
|
||||
rankingOthersLabel = "Others"
|
||||
rankingUnknownVendor = "Unknown"
|
||||
)
|
||||
|
||||
type RankingsResponse struct {
|
||||
Models []RankedModel `json:"models"`
|
||||
Vendors []RankedVendor `json:"vendors"`
|
||||
TopMovers []RankingMover `json:"top_movers"`
|
||||
TopDroppers []RankingMover `json:"top_droppers"`
|
||||
ModelsHistory ModelHistorySeries `json:"models_history"`
|
||||
VendorShareHistory VendorShareSeries `json:"vendor_share_history"`
|
||||
}
|
||||
|
||||
type RankedModel struct {
|
||||
Rank int `json:"rank"`
|
||||
PreviousRank *int `json:"previous_rank,omitempty"`
|
||||
ModelName string `json:"model_name"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
Category string `json:"category"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Share float64 `json:"share"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
}
|
||||
|
||||
type RankedVendor struct {
|
||||
Rank int `json:"rank"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Share float64 `json:"share"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
ModelsCount int `json:"models_count"`
|
||||
TopModel string `json:"top_model"`
|
||||
}
|
||||
|
||||
type RankingMover struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
RankDelta int `json:"rank_delta"`
|
||||
CurrentRank int `json:"current_rank"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
}
|
||||
|
||||
type ModelHistoryPoint struct {
|
||||
Ts string `json:"ts"`
|
||||
Label string `json:"label"`
|
||||
Model string `json:"model"`
|
||||
Vendor string `json:"vendor"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
type ModelHistoryModel struct {
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type ModelHistorySeries struct {
|
||||
Points []ModelHistoryPoint `json:"points"`
|
||||
Models []ModelHistoryModel `json:"models"`
|
||||
Buckets int `json:"buckets"`
|
||||
}
|
||||
|
||||
type VendorSharePoint struct {
|
||||
Ts string `json:"ts"`
|
||||
Label string `json:"label"`
|
||||
Vendor string `json:"vendor"`
|
||||
Share float64 `json:"share"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
type VendorShareVendor struct {
|
||||
Name string `json:"name"`
|
||||
Total int64 `json:"total"`
|
||||
Share float64 `json:"share"`
|
||||
}
|
||||
|
||||
type VendorShareSeries struct {
|
||||
Points []VendorSharePoint `json:"points"`
|
||||
Vendors []VendorShareVendor `json:"vendors"`
|
||||
Buckets int `json:"buckets"`
|
||||
}
|
||||
|
||||
type rankingPeriodConfig struct {
|
||||
id string
|
||||
duration time.Duration
|
||||
bucketSize int64
|
||||
labelLayout string
|
||||
hasPrevious bool
|
||||
}
|
||||
|
||||
type rankingCacheItem struct {
|
||||
expiresAt time.Time
|
||||
data *RankingsResponse
|
||||
}
|
||||
|
||||
type rankingModelMeta struct {
|
||||
vendor string
|
||||
vendorIcon string
|
||||
}
|
||||
|
||||
type vendorAggregate struct {
|
||||
name string
|
||||
icon string
|
||||
totalTokens int64
|
||||
previousTokens int64
|
||||
models map[string]struct{}
|
||||
topModel string
|
||||
topModelTokens int64
|
||||
}
|
||||
|
||||
var (
|
||||
rankingCacheMu sync.Mutex
|
||||
rankingCache = map[string]rankingCacheItem{}
|
||||
)
|
||||
|
||||
func GetRankingsSnapshot(period string) (*RankingsResponse, error) {
|
||||
config, err := rankingConfig(period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rankingCacheMu.Lock()
|
||||
if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) {
|
||||
rankingCacheMu.Unlock()
|
||||
return item.data, nil
|
||||
}
|
||||
rankingCacheMu.Unlock()
|
||||
|
||||
data, err := buildRankingsSnapshot(config, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rankingCacheMu.Lock()
|
||||
rankingCache[config.id] = rankingCacheItem{
|
||||
expiresAt: now.Add(rankingCacheTTL),
|
||||
data: data,
|
||||
}
|
||||
rankingCacheMu.Unlock()
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func rankingConfig(period string) (rankingPeriodConfig, error) {
|
||||
switch period {
|
||||
case "", "week":
|
||||
return rankingPeriodConfig{id: "week", duration: 7 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "today":
|
||||
return rankingPeriodConfig{id: "today", duration: 24 * time.Hour, bucketSize: 3600, labelLayout: "15:04", hasPrevious: true}, nil
|
||||
case "month":
|
||||
return rankingPeriodConfig{id: "month", duration: 30 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "year":
|
||||
return rankingPeriodConfig{id: "year", duration: 365 * 24 * time.Hour, bucketSize: 7 * 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "all":
|
||||
return rankingPeriodConfig{id: "all", bucketSize: 30 * 24 * 3600, labelLayout: "Jan 2006"}, nil
|
||||
default:
|
||||
return rankingPeriodConfig{}, fmt.Errorf("invalid ranking period: %s", period)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) {
|
||||
startTime, endTime := rankingTimeRange(config, now)
|
||||
currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentBuckets, err := model.GetRankingQuotaBuckets(startTime, endTime, config.bucketSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var previousTotals []model.RankingQuotaTotal
|
||||
if config.hasPrevious {
|
||||
previousStart, previousEnd := previousRankingTimeRange(config, startTime)
|
||||
previousTotals, err = model.GetRankingQuotaTotals(previousStart, previousEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meta := buildRankingModelMeta()
|
||||
totalTokens := sumRankingTokens(currentTotals)
|
||||
previousRankByModel := rankingRankMap(previousTotals)
|
||||
previousTokensByModel := rankingTokenMap(previousTotals)
|
||||
|
||||
rankedModels := buildRankedModels(currentTotals, totalTokens, previousRankByModel, previousTokensByModel, meta, config.hasPrevious)
|
||||
vendors := buildRankedVendors(currentTotals, previousTotals, totalTokens, meta, config.hasPrevious)
|
||||
modelHistory := buildModelHistory(currentBuckets, currentTotals, meta, config)
|
||||
vendorHistory := buildVendorShareHistory(currentBuckets, vendors, totalTokens, meta, config)
|
||||
movers, droppers := buildRankingMovers(rankedModels)
|
||||
|
||||
return &RankingsResponse{
|
||||
Models: limitRankedModels(rankedModels, rankingLeaderboardLimit),
|
||||
Vendors: vendors,
|
||||
TopMovers: movers,
|
||||
TopDroppers: droppers,
|
||||
ModelsHistory: modelHistory,
|
||||
VendorShareHistory: vendorHistory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rankingTimeRange(config rankingPeriodConfig, now time.Time) (int64, int64) {
|
||||
endTime := now.Unix()
|
||||
if config.duration <= 0 {
|
||||
return 0, endTime
|
||||
}
|
||||
return now.Add(-config.duration).Unix(), endTime
|
||||
}
|
||||
|
||||
func previousRankingTimeRange(config rankingPeriodConfig, currentStart int64) (int64, int64) {
|
||||
previousEnd := currentStart - 1
|
||||
previousStart := time.Unix(currentStart, 0).Add(-config.duration).Unix()
|
||||
return previousStart, previousEnd
|
||||
}
|
||||
|
||||
func buildRankingModelMeta() map[string]rankingModelMeta {
|
||||
vendorByID := make(map[int]model.PricingVendor)
|
||||
for _, vendor := range model.GetVendors() {
|
||||
vendorByID[vendor.ID] = vendor
|
||||
}
|
||||
|
||||
meta := make(map[string]rankingModelMeta)
|
||||
for _, pricing := range model.GetPricing() {
|
||||
item := rankingModelMeta{vendor: rankingUnknownVendor}
|
||||
if vendor, ok := vendorByID[pricing.VendorID]; ok {
|
||||
item.vendor = vendor.Name
|
||||
item.vendorIcon = vendor.Icon
|
||||
} else if pricing.OwnerBy != "" {
|
||||
item.vendor = pricing.OwnerBy
|
||||
}
|
||||
meta[pricing.ModelName] = item
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func modelMeta(modelName string, meta map[string]rankingModelMeta) rankingModelMeta {
|
||||
if item, ok := meta[modelName]; ok && item.vendor != "" {
|
||||
return item
|
||||
}
|
||||
return rankingModelMeta{vendor: rankingUnknownVendor}
|
||||
}
|
||||
|
||||
func buildRankedModels(totals []model.RankingQuotaTotal, totalTokens int64, previousRanks map[string]int, previousTokens map[string]int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedModel {
|
||||
rows := make([]RankedModel, 0, len(totals))
|
||||
for idx, item := range totals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
var previousRank *int
|
||||
if rank, ok := previousRanks[item.ModelName]; ok {
|
||||
rankCopy := rank
|
||||
previousRank = &rankCopy
|
||||
}
|
||||
growth := 0.0
|
||||
if showGrowth {
|
||||
growth = rankingGrowthPct(item.TotalTokens, previousTokens[item.ModelName])
|
||||
}
|
||||
rows = append(rows, RankedModel{
|
||||
Rank: idx + 1,
|
||||
PreviousRank: previousRank,
|
||||
ModelName: item.ModelName,
|
||||
Vendor: modelMeta.vendor,
|
||||
VendorIcon: modelMeta.vendorIcon,
|
||||
Category: "all",
|
||||
TotalTokens: item.TotalTokens,
|
||||
Share: rankingShare(item.TotalTokens, totalTokens),
|
||||
GrowthPct: growth,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func buildRankedVendors(currentTotals []model.RankingQuotaTotal, previousTotals []model.RankingQuotaTotal, totalTokens int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedVendor {
|
||||
aggregates := make(map[string]*vendorAggregate)
|
||||
for _, item := range currentTotals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
agg := ensureVendorAggregate(aggregates, modelMeta)
|
||||
agg.totalTokens += item.TotalTokens
|
||||
agg.models[item.ModelName] = struct{}{}
|
||||
if item.TotalTokens > agg.topModelTokens {
|
||||
agg.topModel = item.ModelName
|
||||
agg.topModelTokens = item.TotalTokens
|
||||
}
|
||||
}
|
||||
for _, item := range previousTotals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
agg := ensureVendorAggregate(aggregates, modelMeta)
|
||||
agg.previousTokens += item.TotalTokens
|
||||
}
|
||||
|
||||
rows := make([]RankedVendor, 0, len(aggregates))
|
||||
for _, agg := range aggregates {
|
||||
if agg.totalTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
growth := 0.0
|
||||
if showGrowth {
|
||||
growth = rankingGrowthPct(agg.totalTokens, agg.previousTokens)
|
||||
}
|
||||
rows = append(rows, RankedVendor{
|
||||
Vendor: agg.name,
|
||||
VendorIcon: agg.icon,
|
||||
TotalTokens: agg.totalTokens,
|
||||
Share: rankingShare(agg.totalTokens, totalTokens),
|
||||
GrowthPct: growth,
|
||||
ModelsCount: len(agg.models),
|
||||
TopModel: agg.topModel,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].TotalTokens == rows[j].TotalTokens {
|
||||
return rows[i].Vendor < rows[j].Vendor
|
||||
}
|
||||
return rows[i].TotalTokens > rows[j].TotalTokens
|
||||
})
|
||||
for idx := range rows {
|
||||
rows[idx].Rank = idx + 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func ensureVendorAggregate(aggregates map[string]*vendorAggregate, meta rankingModelMeta) *vendorAggregate {
|
||||
name := meta.vendor
|
||||
if name == "" {
|
||||
name = rankingUnknownVendor
|
||||
}
|
||||
agg, ok := aggregates[name]
|
||||
if !ok {
|
||||
agg = &vendorAggregate{
|
||||
name: name,
|
||||
icon: meta.vendorIcon,
|
||||
models: make(map[string]struct{}),
|
||||
}
|
||||
aggregates[name] = agg
|
||||
}
|
||||
if agg.icon == "" && meta.vendorIcon != "" {
|
||||
agg.icon = meta.vendorIcon
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
func buildModelHistory(buckets []model.RankingQuotaBucket, totals []model.RankingQuotaTotal, meta map[string]rankingModelMeta, config rankingPeriodConfig) ModelHistorySeries {
|
||||
topModels := make(map[string]struct{})
|
||||
models := make([]ModelHistoryModel, 0, minInt(len(totals), rankingHistoryLimit)+1)
|
||||
otherTotal := int64(0)
|
||||
for idx, item := range totals {
|
||||
if idx < rankingHistoryLimit {
|
||||
topModels[item.ModelName] = struct{}{}
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
models = append(models, ModelHistoryModel{Name: item.ModelName, Vendor: modelMeta.vendor, Total: item.TotalTokens})
|
||||
continue
|
||||
}
|
||||
otherTotal += item.TotalTokens
|
||||
}
|
||||
if otherTotal > 0 {
|
||||
models = append(models, ModelHistoryModel{Name: rankingOthersLabel, Vendor: "Various", Total: otherTotal})
|
||||
}
|
||||
|
||||
bucketSet := make(map[int64]struct{})
|
||||
tokensByBucketAndModel := make(map[int64]map[string]int64)
|
||||
for _, item := range buckets {
|
||||
modelName := item.ModelName
|
||||
if _, ok := topModels[modelName]; !ok {
|
||||
modelName = rankingOthersLabel
|
||||
}
|
||||
bucketSet[item.Bucket] = struct{}{}
|
||||
if _, ok := tokensByBucketAndModel[item.Bucket]; !ok {
|
||||
tokensByBucketAndModel[item.Bucket] = make(map[string]int64)
|
||||
}
|
||||
tokensByBucketAndModel[item.Bucket][modelName] += item.Tokens
|
||||
}
|
||||
|
||||
sortedBuckets := sortedRankingBuckets(bucketSet)
|
||||
points := make([]ModelHistoryPoint, 0, len(sortedBuckets)*len(models))
|
||||
for _, bucket := range sortedBuckets {
|
||||
for _, historyModel := range models {
|
||||
tokens := tokensByBucketAndModel[bucket][historyModel.Name]
|
||||
if tokens <= 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, ModelHistoryPoint{
|
||||
Ts: rankingBucketTs(bucket),
|
||||
Label: rankingBucketLabel(bucket, config),
|
||||
Model: historyModel.Name,
|
||||
Vendor: historyModel.Vendor,
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ModelHistorySeries{
|
||||
Points: points,
|
||||
Models: models,
|
||||
Buckets: len(sortedBuckets),
|
||||
}
|
||||
}
|
||||
|
||||
func buildVendorShareHistory(buckets []model.RankingQuotaBucket, vendors []RankedVendor, totalTokens int64, meta map[string]rankingModelMeta, config rankingPeriodConfig) VendorShareSeries {
|
||||
topVendors := make(map[string]struct{})
|
||||
vendorRows := make([]VendorShareVendor, 0, minInt(len(vendors), rankingVendorLimit)+1)
|
||||
otherTotal := int64(0)
|
||||
for idx, vendor := range vendors {
|
||||
if idx < rankingVendorLimit {
|
||||
topVendors[vendor.Vendor] = struct{}{}
|
||||
vendorRows = append(vendorRows, VendorShareVendor{Name: vendor.Vendor, Total: vendor.TotalTokens, Share: vendor.Share})
|
||||
continue
|
||||
}
|
||||
otherTotal += vendor.TotalTokens
|
||||
}
|
||||
if otherTotal > 0 {
|
||||
vendorRows = append(vendorRows, VendorShareVendor{Name: rankingOthersLabel, Total: otherTotal, Share: rankingShare(otherTotal, totalTokens)})
|
||||
}
|
||||
|
||||
bucketSet := make(map[int64]struct{})
|
||||
tokensByBucketAndVendor := make(map[int64]map[string]int64)
|
||||
totalsByBucket := make(map[int64]int64)
|
||||
for _, item := range buckets {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
vendorName := modelMeta.vendor
|
||||
if _, ok := topVendors[vendorName]; !ok {
|
||||
vendorName = rankingOthersLabel
|
||||
}
|
||||
bucketSet[item.Bucket] = struct{}{}
|
||||
if _, ok := tokensByBucketAndVendor[item.Bucket]; !ok {
|
||||
tokensByBucketAndVendor[item.Bucket] = make(map[string]int64)
|
||||
}
|
||||
tokensByBucketAndVendor[item.Bucket][vendorName] += item.Tokens
|
||||
totalsByBucket[item.Bucket] += item.Tokens
|
||||
}
|
||||
|
||||
sortedBuckets := sortedRankingBuckets(bucketSet)
|
||||
points := make([]VendorSharePoint, 0, len(sortedBuckets)*len(vendorRows))
|
||||
for _, bucket := range sortedBuckets {
|
||||
for _, vendor := range vendorRows {
|
||||
tokens := tokensByBucketAndVendor[bucket][vendor.Name]
|
||||
if tokens <= 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, VendorSharePoint{
|
||||
Ts: rankingBucketTs(bucket),
|
||||
Label: rankingBucketLabel(bucket, config),
|
||||
Vendor: vendor.Name,
|
||||
Share: rankingShare(tokens, totalsByBucket[bucket]),
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return VendorShareSeries{
|
||||
Points: points,
|
||||
Vendors: vendorRows,
|
||||
Buckets: len(sortedBuckets),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) {
|
||||
movers := make([]RankingMover, 0)
|
||||
droppers := make([]RankingMover, 0)
|
||||
for _, item := range models {
|
||||
if item.PreviousRank == nil {
|
||||
continue
|
||||
}
|
||||
delta := *item.PreviousRank - item.Rank
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
row := RankingMover{
|
||||
ModelName: item.ModelName,
|
||||
Vendor: item.Vendor,
|
||||
VendorIcon: item.VendorIcon,
|
||||
RankDelta: delta,
|
||||
CurrentRank: item.Rank,
|
||||
GrowthPct: item.GrowthPct,
|
||||
}
|
||||
if delta > 0 {
|
||||
movers = append(movers, row)
|
||||
} else {
|
||||
droppers = append(droppers, row)
|
||||
}
|
||||
}
|
||||
sort.Slice(movers, func(i, j int) bool {
|
||||
if movers[i].RankDelta == movers[j].RankDelta {
|
||||
return movers[i].GrowthPct > movers[j].GrowthPct
|
||||
}
|
||||
return movers[i].RankDelta > movers[j].RankDelta
|
||||
})
|
||||
sort.Slice(droppers, func(i, j int) bool {
|
||||
if droppers[i].RankDelta == droppers[j].RankDelta {
|
||||
return droppers[i].GrowthPct < droppers[j].GrowthPct
|
||||
}
|
||||
return droppers[i].RankDelta < droppers[j].RankDelta
|
||||
})
|
||||
return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit)
|
||||
}
|
||||
|
||||
func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 {
|
||||
buckets := make([]int64, 0, len(bucketSet))
|
||||
for bucket := range bucketSet {
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i] < buckets[j]
|
||||
})
|
||||
return buckets
|
||||
}
|
||||
|
||||
func rankingBucketTs(bucket int64) string {
|
||||
return time.Unix(bucket, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func rankingBucketLabel(bucket int64, config rankingPeriodConfig) string {
|
||||
return time.Unix(bucket, 0).Format(config.labelLayout)
|
||||
}
|
||||
|
||||
func rankingRankMap(totals []model.RankingQuotaTotal) map[string]int {
|
||||
ranks := make(map[string]int, len(totals))
|
||||
for idx, item := range totals {
|
||||
ranks[item.ModelName] = idx + 1
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
func rankingTokenMap(totals []model.RankingQuotaTotal) map[string]int64 {
|
||||
tokens := make(map[string]int64, len(totals))
|
||||
for _, item := range totals {
|
||||
tokens[item.ModelName] = item.TotalTokens
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func sumRankingTokens(totals []model.RankingQuotaTotal) int64 {
|
||||
total := int64(0)
|
||||
for _, item := range totals {
|
||||
total += item.TotalTokens
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func rankingShare(value int64, total int64) float64 {
|
||||
if total <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return roundRankingFloat(float64(value) / float64(total))
|
||||
}
|
||||
|
||||
func rankingGrowthPct(current int64, previous int64) float64 {
|
||||
if previous <= 0 {
|
||||
if current > 0 {
|
||||
return 100
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return roundRankingFloat((float64(current-previous) / float64(previous)) * 100)
|
||||
}
|
||||
|
||||
func roundRankingFloat(value float64) float64 {
|
||||
return math.Round(value*10000) / 10000
|
||||
}
|
||||
|
||||
func limitRankedModels(rows []RankedModel, limit int) []RankedModel {
|
||||
if limit <= 0 || len(rows) <= limit {
|
||||
return rows
|
||||
}
|
||||
return rows[:limit]
|
||||
}
|
||||
|
||||
func limitRankingMovers(rows []RankingMover, limit int) []RankingMover {
|
||||
if limit <= 0 || len(rows) <= limit {
|
||||
return rows
|
||||
}
|
||||
return rows[:limit]
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@ -474,6 +474,6 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
Other: other,
|
||||
})
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, true)
|
||||
perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
|
||||
})
|
||||
}
|
||||
|
||||
@ -87,6 +87,14 @@ export type DataTableToolbarProps<TData> = {
|
||||
* Hide the View Options (column visibility) dropdown.
|
||||
*/
|
||||
hideViewOptions?: boolean
|
||||
/**
|
||||
* Content rendered on the LEFT side of the secondary action row. When
|
||||
* provided the toolbar splits into two visual rows:
|
||||
* Row 1: search inputs / filter chips …… Expand
|
||||
* Row 2: expanded filters
|
||||
* Row 3: leftActions …… Reset / Search / ViewOptions
|
||||
*/
|
||||
leftActions?: ReactNode
|
||||
/**
|
||||
* Outer wrapper className override.
|
||||
*/
|
||||
@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const hasLeftActions = props.leftActions != null
|
||||
|
||||
if (hasLeftActions) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', props.className)}>
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{expandToggle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandable && (
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.expandable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
|
||||
{props.leftActions}
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewOptionsNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
8
web/default/src/features/pricing/api.ts
vendored
8
web/default/src/features/pricing/api.ts
vendored
@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = {
|
||||
avg_ttft_ms: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
count: number
|
||||
success_count: number
|
||||
ttft_count: number
|
||||
avg_tps: number
|
||||
}
|
||||
|
||||
export type PerformanceGroup = {
|
||||
@ -26,9 +24,7 @@ export type PerformanceGroup = {
|
||||
avg_ttft_ms: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
request_count: number
|
||||
success_count: number
|
||||
ttft_count: number
|
||||
avg_tps: number
|
||||
series: PerformanceSeriesPoint[]
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ function formatDayLabel(date: string): string {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Latency trend chart (24h, multi-group line chart)
|
||||
// Latency trend chart (24h, multi-group point-line chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LatencyTrendChart(props: {
|
||||
@ -52,14 +52,20 @@ export function LatencyTrendChart(props: {
|
||||
yField: 'ttft',
|
||||
seriesField: 'group',
|
||||
smooth: true,
|
||||
point: { visible: false },
|
||||
legends: { visible: true, orient: 'top', position: 'start' },
|
||||
point: {
|
||||
visible: true,
|
||||
style: { size: 5, stroke: '#ffffff', lineWidth: 1.5 },
|
||||
},
|
||||
line: {
|
||||
style: { lineWidth: 2 },
|
||||
},
|
||||
legends: { visible: false },
|
||||
tooltip: {
|
||||
mark: {
|
||||
title: { value: (d: { time: string }) => d.time },
|
||||
content: [
|
||||
{
|
||||
key: (d: { group: string }) => d.group,
|
||||
key: t('Average TTFT'),
|
||||
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
|
||||
},
|
||||
],
|
||||
@ -83,7 +89,7 @@ export function LatencyTrendChart(props: {
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [props.series])
|
||||
}, [props.series, t])
|
||||
|
||||
if (props.series.length === 0) {
|
||||
return (
|
||||
@ -116,10 +122,10 @@ export function LatencyTrendChart(props: {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uptime bar chart (30 days)
|
||||
// Uptime trend chart (24h, point-line chart)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function UptimeBarChart(props: {
|
||||
export function UptimeTrendChart(props: {
|
||||
series: UptimeDayPoint[]
|
||||
className?: string
|
||||
}) {
|
||||
@ -137,18 +143,25 @@ export function UptimeBarChart(props: {
|
||||
}))
|
||||
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
type: 'line' as const,
|
||||
data: [{ id: 'uptime', values: data }],
|
||||
xField: 'date',
|
||||
yField: 'uptime',
|
||||
bar: {
|
||||
smooth: true,
|
||||
line: {
|
||||
style: { stroke: '#10b981', lineWidth: 2 },
|
||||
},
|
||||
point: {
|
||||
visible: true,
|
||||
style: {
|
||||
size: 5,
|
||||
stroke: '#ffffff',
|
||||
lineWidth: 1.5,
|
||||
fill: (datum: { uptime: number }) => {
|
||||
if (datum.uptime >= 99.9) return '#10b981'
|
||||
if (datum.uptime >= 99.0) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
},
|
||||
cornerRadius: 2,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
|
||||
<div className={cn('h-56 sm:h-64', props.className)}>
|
||||
{themeReady && spec && (
|
||||
<VChart
|
||||
key={`uptime-${resolvedTheme}`}
|
||||
key={`uptime-trend-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
HeartPulse,
|
||||
Timer,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@ -21,18 +15,14 @@ import { GroupBadge } from '@/components/group-badge'
|
||||
import { getPerfMetrics, type PerformanceGroup } from '../api'
|
||||
import {
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
type UptimeDayPoint,
|
||||
} from '../lib/mock-stats'
|
||||
import type { PricingModel } from '../types'
|
||||
import { LatencyTrendChart, UptimeBarChart } from './model-details-charts'
|
||||
import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts'
|
||||
import { UptimeSparkline } from './model-details-uptime-sparkline'
|
||||
|
||||
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
function StatCard(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
@ -71,39 +61,55 @@ type PerformanceRow = {
|
||||
avg_ttft_ms: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
request_count: number
|
||||
avg_tps: number
|
||||
}
|
||||
|
||||
function toLatencySeries(groups: PerformanceGroup[]) {
|
||||
return groups.flatMap((group) =>
|
||||
group.series
|
||||
.filter((point) => point.ttft_count > 0 && point.avg_ttft_ms > 0)
|
||||
.map((point) => ({
|
||||
timestamp: new Date(point.ts * 1000).toISOString(),
|
||||
group: group.group,
|
||||
ttft_ms: point.avg_ttft_ms,
|
||||
}))
|
||||
)
|
||||
const byTs = new Map<number, number[]>()
|
||||
for (const group of groups) {
|
||||
for (const point of group.series) {
|
||||
if (point.avg_ttft_ms <= 0) continue
|
||||
const current = byTs.get(point.ts) ?? []
|
||||
current.push(point.avg_ttft_ms)
|
||||
byTs.set(point.ts, current)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byTs.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, values]) => ({
|
||||
timestamp: new Date(ts * 1000).toISOString(),
|
||||
group: 'latency',
|
||||
ttft_ms: Math.round(
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] {
|
||||
const byTs = new Map<number, { count: number; success: number }>()
|
||||
const byTs = new Map<number, { rates: number[]; incidents: number }>()
|
||||
for (const group of groups) {
|
||||
for (const point of group.series) {
|
||||
const current = byTs.get(point.ts) ?? { count: 0, success: 0 }
|
||||
current.count += point.count
|
||||
current.success += point.success_count
|
||||
const current = byTs.get(point.ts) ?? { rates: [], incidents: 0 }
|
||||
if (Number.isFinite(point.success_rate)) {
|
||||
current.rates.push(point.success_rate)
|
||||
if (point.success_rate < 100) current.incidents += 1
|
||||
}
|
||||
byTs.set(point.ts, current)
|
||||
}
|
||||
}
|
||||
return Array.from(byTs.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([ts, value]) => {
|
||||
const uptime = value.count > 0 ? (value.success / value.count) * 100 : 0
|
||||
const uptime =
|
||||
value.rates.length > 0
|
||||
? value.rates.reduce((sum, rate) => sum + rate, 0) /
|
||||
value.rates.length
|
||||
: 0
|
||||
return {
|
||||
date: new Date(ts * 1000).toISOString(),
|
||||
uptime_pct: Math.round(uptime * 100) / 100,
|
||||
incidents: value.success < value.count ? 1 : 0,
|
||||
incidents: value.incidents,
|
||||
outage_minutes: 0,
|
||||
}
|
||||
})
|
||||
@ -113,23 +119,20 @@ function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] {
|
||||
return group.series.map((point) => ({
|
||||
date: new Date(point.ts * 1000).toISOString(),
|
||||
uptime_pct: Math.round(point.success_rate * 100) / 100,
|
||||
incidents: point.success_count < point.count ? 1 : 0,
|
||||
incidents: point.success_rate < 100 ? 1 : 0,
|
||||
outage_minutes: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
function weightedAverage(
|
||||
function average(
|
||||
rows: PerformanceRow[],
|
||||
field: 'avg_ttft_ms' | 'avg_latency_ms'
|
||||
): number {
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const row of rows) {
|
||||
if (row[field] <= 0 || row.request_count <= 0) continue
|
||||
total += row[field] * row.request_count
|
||||
count += row.request_count
|
||||
}
|
||||
return count > 0 ? Math.round(total / count) : 0
|
||||
) {
|
||||
const values = rows.map((row) => row[field]).filter((value) => value > 0)
|
||||
if (values.length === 0) return 0
|
||||
return Math.round(
|
||||
values.reduce((sum, value) => sum + value, 0) / values.length
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
@ -147,7 +150,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
avg_ttft_ms: group.avg_ttft_ms,
|
||||
avg_latency_ms: group.avg_latency_ms,
|
||||
success_rate: group.success_rate,
|
||||
request_count: group.request_count,
|
||||
avg_tps: group.avg_tps,
|
||||
})),
|
||||
[groups]
|
||||
)
|
||||
@ -169,15 +172,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
)
|
||||
}
|
||||
|
||||
const ttftValues = performances
|
||||
.map((p) => p.avg_ttft_ms)
|
||||
const tpsValues = performances
|
||||
.map((p) => p.avg_tps)
|
||||
.filter((value) => value > 0)
|
||||
const bestTtft = ttftValues.length > 0 ? Math.min(...ttftValues) : 0
|
||||
const avgLatency = weightedAverage(performances, 'avg_latency_ms')
|
||||
const totalRequests = performances.reduce((s, p) => s + p.request_count, 0)
|
||||
const totalSuccess = groups.reduce((s, p) => s + p.success_count, 0)
|
||||
const avgTps =
|
||||
tpsValues.length > 0
|
||||
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
|
||||
: 0
|
||||
const avgLatency = average(performances, 'avg_latency_ms')
|
||||
const successRates = performances
|
||||
.map((perf) => perf.success_rate)
|
||||
.filter((value) => Number.isFinite(value))
|
||||
const successRate =
|
||||
totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0
|
||||
successRates.length > 0
|
||||
? successRates.reduce((sum, value) => sum + value, 0) /
|
||||
successRates.length
|
||||
: 0
|
||||
const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
|
||||
let intent: 'default' | 'warning' | 'success' = 'warning'
|
||||
if (successRate >= 99.9) {
|
||||
@ -191,18 +201,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-2 lg:grid-cols-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
<StatCard
|
||||
icon={Timer}
|
||||
label={t('Best TTFT')}
|
||||
value={formatLatency(bestTtft)}
|
||||
hint={t('Lowest median first-token latency')}
|
||||
label='TPS'
|
||||
value={formatThroughput(avgTps)}
|
||||
hint={t('Sustained tokens per second')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(avgLatency)}
|
||||
hint={t('Across all groups')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={HeartPulse}
|
||||
@ -217,25 +226,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
}
|
||||
intent={intent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('Requests (24h)')}
|
||||
value={COMPACT_NUMBER.format(totalRequests)}
|
||||
hint={t('Aggregated across enabled groups')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
icon={Activity}
|
||||
icon={HeartPulse}
|
||||
title={t('Per-group performance')}
|
||||
description={t('Average latency, TTFT, and success rate by group')}
|
||||
description={t('Average latency, TTFT, TPS, and success rate')}
|
||||
/>
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
TPS
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Average TTFT')}
|
||||
</TableHead>
|
||||
@ -243,46 +249,35 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={`${headerCellClass} min-w-[160px] text-left`}
|
||||
className={`${headerCellClass} min-w-[180px] text-left`}
|
||||
>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Request Count')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => {
|
||||
const isBestTtft = perf.avg_ttft_ms === bestTtft
|
||||
return (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'py-2.5 text-right font-mono',
|
||||
isBestTtft && 'text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{COMPACT_NUMBER.format(perf.request_count)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{performances.map((perf) => (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatThroughput(perf.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@ -292,7 +287,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
<SectionHeader
|
||||
icon={Timer}
|
||||
title={t('Latency trend (last 24h)')}
|
||||
description={t('Average time-to-first-token (TTFT) by group')}
|
||||
description={t('Average TTFT')}
|
||||
/>
|
||||
<LatencyTrendChart series={latencySeries} />
|
||||
</section>
|
||||
@ -322,7 +317,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<UptimeBarChart series={uptimeSeries} />
|
||||
<UptimeTrendChart series={uptimeSeries} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,16 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
Code2,
|
||||
HeartPulse,
|
||||
Info,
|
||||
ReceiptText,
|
||||
Rocket,
|
||||
} from 'lucide-react'
|
||||
import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { getPerfMetrics } from '../api'
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { usePricingData } from '../hooks/use-pricing-data'
|
||||
import {
|
||||
@ -42,18 +37,23 @@ import {
|
||||
} from '../lib/dynamic-price'
|
||||
import { parseTags } from '../lib/filters'
|
||||
import {
|
||||
getAvailableGroups,
|
||||
isTokenBasedModel,
|
||||
replaceModelInPath,
|
||||
} from '../lib/model-helpers'
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
} from '../lib/mock-stats'
|
||||
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
|
||||
import { inferModelMetadata } from '../lib/model-metadata'
|
||||
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
|
||||
import type { PriceType, PricingModel, TokenUnit } from '../types'
|
||||
import type {
|
||||
Modality,
|
||||
ModelCapability,
|
||||
PriceType,
|
||||
PricingModel,
|
||||
TokenUnit,
|
||||
} from '../types'
|
||||
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
|
||||
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
|
||||
import { ModelDetailsApps } from './model-details-apps'
|
||||
import { ModelDetailsCapabilities } from './model-details-capabilities'
|
||||
import { ModalitiesMatrix } from './model-details-modalities'
|
||||
import { ModalityIcons } from './model-details-modalities'
|
||||
import { ModelDetailsPerformance } from './model-details-performance'
|
||||
import { ModelDetailsQuickStats } from './model-details-quick-stats'
|
||||
|
||||
@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
|
||||
function_calling: 'Function calling',
|
||||
streaming: 'Streaming',
|
||||
vision: 'Vision',
|
||||
json_mode: 'JSON mode',
|
||||
structured_output: 'Structured output',
|
||||
reasoning: 'Reasoning',
|
||||
tools: 'Tools',
|
||||
system_prompt: 'System prompt',
|
||||
web_search: 'Web search',
|
||||
code_interpreter: 'Code interpreter',
|
||||
caching: 'Prompt caching',
|
||||
embeddings: 'Embeddings',
|
||||
}
|
||||
|
||||
function CompactCapabilityList(props: { capabilities: ModelCapability[] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (props.capabilities.length === 0) {
|
||||
return (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('No capabilities reported for this model.')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{props.capabilities.map((capability) => (
|
||||
<span
|
||||
key={capability}
|
||||
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
|
||||
>
|
||||
{t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Input')}
|
||||
</span>
|
||||
<ModalityIcons modalities={props.input} />
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Output')}
|
||||
</span>
|
||||
<ModalityIcons modalities={props.output} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSignalsSection(props: {
|
||||
capabilities: ModelCapability[]
|
||||
input: Modality[]
|
||||
output: Modality[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>
|
||||
{t('Capabilities')} / {t('Supported modalities')}
|
||||
</SectionTitle>
|
||||
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
|
||||
<CompactCapabilityList capabilities={props.capabilities} />
|
||||
<CompactModalities input={props.input} output={props.output} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewMetric(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
intent?: 'default' | 'warning' | 'success'
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
const intent = props.intent ?? 'default'
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
|
||||
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
|
||||
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewSummaryGrid(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics', props.model.model_name],
|
||||
queryFn: () => getPerfMetrics(props.model.model_name, 24),
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
const groups = metricsQuery.data?.data.groups ?? []
|
||||
const successRates = groups
|
||||
.map((group) => group.success_rate)
|
||||
.filter((rate) => Number.isFinite(rate))
|
||||
const successRate =
|
||||
successRates.length > 0
|
||||
? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
|
||||
: Number.NaN
|
||||
let successIntent: 'default' | 'warning' | 'success' = 'warning'
|
||||
if (successRate >= 99.9) {
|
||||
successIntent = 'success'
|
||||
} else if (successRate >= 99) {
|
||||
successIntent = 'default'
|
||||
}
|
||||
const tpsValues = groups
|
||||
.map((group) => group.avg_tps)
|
||||
.filter((value) => value > 0)
|
||||
const avgTps =
|
||||
tpsValues.length > 0
|
||||
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
|
||||
: 0
|
||||
const latencyValues = groups
|
||||
.map((group) => group.avg_latency_ms)
|
||||
.filter((value) => value > 0)
|
||||
const avgLatency =
|
||||
latencyValues.length > 0
|
||||
? Math.round(
|
||||
latencyValues.reduce((sum, value) => sum + value, 0) /
|
||||
latencyValues.length
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label='TPS'
|
||||
value={formatThroughput(avgTps)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(avgLatency)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(successRate)}
|
||||
intent={successIntent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model header (always visible above the tabs)
|
||||
// Model header (always visible above the detail sections)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function ModelHeader(props: { model: PricingModel }) {
|
||||
@ -362,55 +535,6 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API endpoints list
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function EndpointsSection(props: {
|
||||
model: PricingModel
|
||||
endpointMap: Record<string, { path?: string; method?: string }>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const endpoints = useMemo(() => {
|
||||
const types = props.model.supported_endpoint_types || []
|
||||
return types.map((type) => {
|
||||
const info = props.endpointMap[type] || {}
|
||||
let path = info.path || ''
|
||||
if (path.includes('{model}')) {
|
||||
path = replaceModelInPath(path, props.model.model_name || '')
|
||||
}
|
||||
return { type, path, method: info.method || 'POST' }
|
||||
})
|
||||
}, [props.model, props.endpointMap])
|
||||
|
||||
if (endpoints.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('API Endpoints')}</SectionTitle>
|
||||
<div className='space-y-1'>
|
||||
{endpoints.map(({ type, path, method }) => (
|
||||
<div key={type} className='flex items-center justify-between py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm font-medium'>{type}</span>
|
||||
{path && (
|
||||
<code className='text-muted-foreground/60 text-xs break-all'>
|
||||
{path}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{path && (
|
||||
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase'>
|
||||
{method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Auto group chain (used inside group pricing section)
|
||||
// ----------------------------------------------------------------------------
|
||||
@ -740,17 +864,7 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tabbed details content
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const TAB_VALUES = [
|
||||
'overview',
|
||||
'pricing',
|
||||
'performance',
|
||||
'api',
|
||||
'apps',
|
||||
] as const
|
||||
const TAB_VALUES = ['overview', 'performance', 'api'] as const
|
||||
type TabValue = (typeof TAB_VALUES)[number]
|
||||
|
||||
const TAB_META: Record<
|
||||
@ -758,10 +872,8 @@ const TAB_META: Record<
|
||||
{ icon: React.ComponentType<{ className?: string }>; labelKey: string }
|
||||
> = {
|
||||
overview: { icon: Info, labelKey: 'Overview' },
|
||||
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
|
||||
performance: { icon: HeartPulse, labelKey: 'Performance' },
|
||||
api: { icon: Code2, labelKey: 'API' },
|
||||
apps: { icon: Rocket, labelKey: 'Apps' },
|
||||
}
|
||||
|
||||
export interface ModelDetailsContentProps {
|
||||
@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
<div className='@container/details space-y-4'>
|
||||
<ModelHeader model={props.model} />
|
||||
|
||||
<ModelDetailsQuickStats metadata={metadata} />
|
||||
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
|
||||
{TAB_VALUES.map((value) => {
|
||||
@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-5 outline-none'>
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<Boxes className='text-muted-foreground/70 size-3.5' />
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Capabilities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
|
||||
</section>
|
||||
<TabsContent value='overview' className='space-y-6 outline-none'>
|
||||
<OverviewSummaryGrid model={props.model} />
|
||||
|
||||
<section>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<h3 className='text-foreground text-sm font-semibold'>
|
||||
{t('Supported modalities')}
|
||||
</h3>
|
||||
</div>
|
||||
<ModalitiesMatrix
|
||||
input={metadata.input_modalities}
|
||||
output={metadata.output_modalities}
|
||||
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModelDetailsQuickStats metadata={metadata} />
|
||||
|
||||
<ModelSignalsSection
|
||||
capabilities={metadata.capabilities}
|
||||
input={metadata.input_modalities}
|
||||
output={metadata.output_modalities}
|
||||
/>
|
||||
|
||||
<ModelDetailsProviderInfo model={props.model} />
|
||||
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
|
||||
<EndpointsSection
|
||||
model={props.model}
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pricing' className='space-y-5 outline-none'>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='performance' className='outline-none'>
|
||||
@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
endpointMap={props.endpointMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='apps' className='outline-none'>
|
||||
<ModelDetailsApps model={props.model} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
15
web/default/src/features/rankings/api.ts
vendored
Normal file
15
web/default/src/features/rankings/api.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type { RankingPeriod, RankingsSnapshot } from './types'
|
||||
|
||||
type RankingsResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: RankingsSnapshot
|
||||
}
|
||||
|
||||
export async function getRankings(
|
||||
period: RankingPeriod
|
||||
): Promise<RankingsResponse> {
|
||||
const res = await api.get('/api/rankings', { params: { period } })
|
||||
return res.data
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
import { ExternalLink, Rocket } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { AppListing } from '../types'
|
||||
import { GrowthText } from './growth-text'
|
||||
|
||||
type AppsSectionProps = {
|
||||
rows: AppListing[]
|
||||
}
|
||||
|
||||
/**
|
||||
* "Top Apps" card — clean two-column listing of the apps consuming the
|
||||
* most tokens through new-api in the active period. Apps don't get a
|
||||
* dedicated chart (each app has too much variance to plot meaningfully);
|
||||
* instead we keep the focus on the leaderboard itself.
|
||||
*/
|
||||
export function AppsSection(props: AppsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const half = Math.ceil(props.rows.length / 2)
|
||||
const left = props.rows.slice(0, half)
|
||||
const right = props.rows.slice(half)
|
||||
|
||||
return (
|
||||
<section className='bg-card overflow-hidden rounded-lg border'>
|
||||
<header className='px-5 py-4'>
|
||||
<h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
|
||||
<Rocket className='text-primary size-4' />
|
||||
{t('Top Apps')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t('Apps using the most tokens through new-api')}
|
||||
</p>
|
||||
</header>
|
||||
{props.rows.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 border-t px-5 py-8 text-center text-sm'>
|
||||
{t('No apps match the selected filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-x-8 border-t px-5 pt-3 pb-4 md:grid-cols-2'>
|
||||
<AppList rows={left} />
|
||||
{right.length > 0 && <AppList rows={right} />}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppList(props: { rows: AppListing[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{props.rows.map((row) => (
|
||||
<li key={row.name} className='flex items-center gap-3 py-2.5'>
|
||||
<span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
|
||||
{row.rank}.
|
||||
</span>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-bold uppercase'>
|
||||
{row.initial}
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2 text-sm font-semibold'>
|
||||
{row.url ? (
|
||||
<a
|
||||
href={row.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground hover:text-primary inline-flex items-center gap-1 truncate transition-colors'
|
||||
>
|
||||
<span className='truncate'>{row.name}</span>
|
||||
<ExternalLink className='text-muted-foreground/60 size-3 shrink-0' />
|
||||
</a>
|
||||
) : (
|
||||
<span className='text-foreground truncate'>{row.name}</span>
|
||||
)}
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-4 shrink-0 rounded-sm px-1 text-[10px] font-normal'
|
||||
>
|
||||
{row.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground/80 truncate text-xs'>
|
||||
{row.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-sm font-semibold tabular-nums'>
|
||||
{formatTokens(row.total_tokens)}
|
||||
</div>
|
||||
<GrowthText value={row.growth_pct} className='text-[11px]' />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@ -1,205 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { VChart } from '@visactor/react-vchart'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { formatTokens } from '../lib/format'
|
||||
import type { CategorySection as CategorySectionData } from '../types'
|
||||
import { ModelLeaderboard } from './model-leaderboard'
|
||||
|
||||
const TOOLTIP_MAX_ROWS = 8
|
||||
const MAX_LEADERBOARD_ROWS = 8
|
||||
|
||||
type CategorySectionProps = {
|
||||
section: CategorySectionData
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category ranking unit: a compact stacked-bar chart of token usage
|
||||
* over time paired with a 2-column leaderboard of the top models in that
|
||||
* category. Renders as a self-contained card; the rankings page stacks
|
||||
* one of these per category for quick browsing.
|
||||
*/
|
||||
export function CategorySection(props: CategorySectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const { resolvedTheme, themeReady } = useChartTheme()
|
||||
|
||||
const orderedPoints = useMemo(() => {
|
||||
const order = new Map(
|
||||
props.section.models_history.models.map(
|
||||
(m, idx) => [m.name, idx] as const
|
||||
)
|
||||
)
|
||||
return [...props.section.models_history.points].sort((a, b) => {
|
||||
const tsCmp = a.ts.localeCompare(b.ts)
|
||||
if (tsCmp !== 0) return tsCmp
|
||||
return (order.get(a.model) ?? 999) - (order.get(b.model) ?? 999)
|
||||
})
|
||||
}, [props.section.models_history])
|
||||
|
||||
const spec = useMemo(() => {
|
||||
if (orderedPoints.length === 0) return null
|
||||
return {
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'category-history', values: orderedPoints }],
|
||||
xField: 'label',
|
||||
yField: 'tokens',
|
||||
seriesField: 'model',
|
||||
stack: true,
|
||||
bar: { style: { cornerRadius: 1 } },
|
||||
legends: { visible: false },
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
style: { fill: 'currentColor', fontSize: 9 },
|
||||
autoHide: true,
|
||||
autoLimit: true,
|
||||
},
|
||||
tick: { visible: false },
|
||||
},
|
||||
{
|
||||
orient: 'left',
|
||||
label: {
|
||||
formatMethod: (val: number | string) => formatTokens(Number(val)),
|
||||
style: { fill: 'currentColor', fontSize: 9 },
|
||||
},
|
||||
grid: { visible: true, style: { lineDash: [3, 3] } },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formatTokens(Number(datum?.tokens) || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
dimension: {
|
||||
title: {
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
String(datum?.label ?? ''),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum: Record<string, unknown>) =>
|
||||
String(datum?.model ?? ''),
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
Number(datum?.tokens) || 0,
|
||||
},
|
||||
],
|
||||
updateContent: (
|
||||
array: Array<{ key: string; value: string | number }>
|
||||
) => {
|
||||
array.sort((a, b) => Number(b.value) - Number(a.value))
|
||||
const visible = array.slice(0, TOOLTIP_MAX_ROWS)
|
||||
return visible.map((item) => ({
|
||||
key: item.key,
|
||||
value: formatTokens(Number(item.value) || 0),
|
||||
}))
|
||||
},
|
||||
},
|
||||
},
|
||||
animationAppear: { duration: 400 },
|
||||
}
|
||||
}, [orderedPoints])
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`category-${props.section.category}`}
|
||||
className='bg-card scroll-mt-20 overflow-hidden rounded-lg border'
|
||||
>
|
||||
<header className='flex items-start justify-between gap-4 px-5 py-3.5'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3 className='text-foreground text-base font-semibold'>
|
||||
{t(props.section.label)}
|
||||
</h3>
|
||||
<p className='text-muted-foreground/80 mt-0.5 truncate text-xs'>
|
||||
{t(props.section.description)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-foreground font-mono text-base font-semibold tabular-nums'>
|
||||
{formatTokens(props.section.total_tokens)}
|
||||
</div>
|
||||
<div className='text-muted-foreground/80 text-[10px] tracking-widest uppercase'>
|
||||
{t('tokens')}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className='px-5 pb-4'>
|
||||
<div className='h-44 sm:h-48'>
|
||||
{themeReady && spec ? (
|
||||
<VChart
|
||||
key={`category-history-${props.section.category}-${resolvedTheme}`}
|
||||
spec={{
|
||||
...spec,
|
||||
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
background: 'transparent',
|
||||
}}
|
||||
option={VCHART_OPTION}
|
||||
/>
|
||||
) : (
|
||||
<div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
|
||||
{t('No history data available')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.section.models.length === 0 ? (
|
||||
<div className='text-muted-foreground/80 border-t px-5 py-6 text-center text-sm'>
|
||||
{t('No models match the selected filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='border-t px-5 pt-2 pb-4'>
|
||||
<ModelLeaderboard
|
||||
rows={props.section.models}
|
||||
limit={MAX_LEADERBOARD_ROWS}
|
||||
variant='compact'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
type CategorySectionsProps = {
|
||||
sections: CategorySectionData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the per-category rankings strip (one card per category).
|
||||
* Includes a strip header so users understand the page structure shifts
|
||||
* from the global view to category drill-downs.
|
||||
*/
|
||||
export function CategorySections(props: CategorySectionsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (props.sections.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className='space-y-5'>
|
||||
<header className='space-y-1'>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-widest uppercase'>
|
||||
{t('By category')}
|
||||
</p>
|
||||
<h2 className='text-foreground text-xl font-semibold tracking-tight'>
|
||||
{t('Browse rankings by category')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
|
||||
{t('Discover the leading models in each domain')}
|
||||
</p>
|
||||
</header>
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
|
||||
{props.sections.map((section) => (
|
||||
<CategorySection key={section.category} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
export * from './apps-section'
|
||||
export * from './category-section'
|
||||
export * from './entity-links'
|
||||
export * from './growth-text'
|
||||
export * from './market-share-section'
|
||||
|
||||
@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
|
||||
all: 'Token share by model author since launch',
|
||||
}
|
||||
|
||||
/** Stable colour palette for vendors, used in both the area chart and the
|
||||
/** Stable colour palette for vendors, used in both the share chart and the
|
||||
* legend dots. Falls back to a neutral palette for unknown vendors so that
|
||||
* future additions still render. */
|
||||
const VENDOR_COLOURS: Record<string, string> = {
|
||||
@ -77,7 +77,7 @@ type MarketShareSectionProps = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined "Market Share" card: a 100%-stacked area chart showing each
|
||||
* Combined "Market Share" card: a 100%-stacked bar chart showing each
|
||||
* vendor's slice of total token volume, paired below with a two-column
|
||||
* vendor list.
|
||||
*/
|
||||
@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
|
||||
const spec = useMemo(() => {
|
||||
if (orderedPoints.length === 0) return null
|
||||
return {
|
||||
type: 'area' as const,
|
||||
type: 'bar' as const,
|
||||
data: [{ id: 'vendor-share', values: orderedPoints }],
|
||||
xField: 'label',
|
||||
yField: 'share',
|
||||
seriesField: 'vendor',
|
||||
stack: true,
|
||||
paddingInner: 0.12,
|
||||
legends: { visible: false },
|
||||
area: {
|
||||
style: { fillOpacity: 0.85, curveType: 'monotone' },
|
||||
},
|
||||
line: { style: { lineWidth: 0, curveType: 'monotone' } },
|
||||
point: { visible: false },
|
||||
bar: { style: { cornerRadius: 1 } },
|
||||
color: { specified: colourMap },
|
||||
axes: [
|
||||
{
|
||||
|
||||
@ -1,33 +1,28 @@
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Sparkles,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatReleaseDate, formatTokens } from '../lib/format'
|
||||
import type { NewModelEntry, RankingMover } from '../types'
|
||||
import type { RankingMover } from '../types'
|
||||
import { ModelLink, VendorLink } from './entity-links'
|
||||
|
||||
type PulseSectionProps = {
|
||||
movers: RankingMover[]
|
||||
droppers: RankingMover[]
|
||||
newModels: NewModelEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-up "Pulse" panel: rank gainers, rank losers, and recently released
|
||||
* models — the "what's changing" footer of the rankings page. Each card
|
||||
* is intentionally compact so the trio fits in one row on desktop.
|
||||
* Rank movement panel: gainers and losers calculated from the previous period.
|
||||
*/
|
||||
export function PulseSection(props: PulseSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<section className='grid grid-cols-1 gap-4 lg:grid-cols-3'>
|
||||
<section className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
<PulseCard
|
||||
title={t('Trending up')}
|
||||
description={t('Models climbing the leaderboard')}
|
||||
@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
|
||||
</ul>
|
||||
)}
|
||||
</PulseCard>
|
||||
|
||||
<PulseCard
|
||||
title={t('Newly released')}
|
||||
description={t('Recently launched models')}
|
||||
icon={<Sparkles className='size-4 text-amber-500' />}
|
||||
>
|
||||
{props.newModels.length === 0 ? (
|
||||
<PulseEmpty label={t('No new models yet')} />
|
||||
) : (
|
||||
<ul>
|
||||
{props.newModels.slice(0, 6).map((row) => (
|
||||
<NewModelRow key={row.model_name} row={row} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</PulseCard>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function NewModelRow(props: { row: NewModelEntry }) {
|
||||
return (
|
||||
<li className='flex items-center gap-3 px-4 py-2'>
|
||||
<span className='shrink-0'>{getLobeIcon(props.row.vendor_icon, 20)}</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<ModelLink
|
||||
modelName={props.row.model_name}
|
||||
className='text-foreground block truncate font-mono text-xs font-medium'
|
||||
>
|
||||
{props.row.model_name}
|
||||
</ModelLink>
|
||||
<p className='text-muted-foreground/80 truncate text-[11px]'>
|
||||
{formatReleaseDate(props.row.release_date)} ·{' '}
|
||||
<VendorLink vendor={props.row.vendor}>
|
||||
{props.row.vendor.toLowerCase()}
|
||||
</VendorLink>
|
||||
</p>
|
||||
</div>
|
||||
<span className='text-foreground shrink-0 font-mono text-xs font-semibold tabular-nums'>
|
||||
{formatTokens(props.row.total_tokens)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,9 +17,7 @@ type RankingsHeroProps = {
|
||||
|
||||
/**
|
||||
* Hero strip for the rankings page. Intentionally minimal — title +
|
||||
* subtitle + period tabs only. Category filtering is no longer needed
|
||||
* because every category is rendered inline as its own section further
|
||||
* down the page.
|
||||
* subtitle + period tabs only.
|
||||
*/
|
||||
export function RankingsHero(props: RankingsHeroProps) {
|
||||
const { t } = useTranslation()
|
||||
@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
|
||||
</h1>
|
||||
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
|
||||
{t(
|
||||
'Discover the most-used models, top apps, and rising vendors on the platform — updated continuously across every category.'
|
||||
'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { buildRankingsSnapshot } from '../lib/mock-rankings'
|
||||
import type { RankingPeriod, RankingsSnapshot } from '../types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRankings } from '../api'
|
||||
import type { RankingPeriod } from '../types'
|
||||
|
||||
/**
|
||||
* Memoised rankings snapshot for a period.
|
||||
*
|
||||
* Currently this synchronously builds deterministic mock data. When the
|
||||
* backend ships real analytics endpoints, swap the body to a
|
||||
* `useQuery`-based fetch — the consuming components don't care which side
|
||||
* produced the data as long as it conforms to {@link RankingsSnapshot}.
|
||||
*/
|
||||
export function useRankings(period: RankingPeriod): RankingsSnapshot {
|
||||
return useMemo(() => buildRankingsSnapshot(period), [period])
|
||||
export function useRankings(period: RankingPeriod) {
|
||||
return useQuery({
|
||||
queryKey: ['rankings', period],
|
||||
queryFn: () => getRankings(period),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
85
web/default/src/features/rankings/index.tsx
vendored
85
web/default/src/features/rankings/index.tsx
vendored
@ -1,10 +1,9 @@
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { PageTransition } from '@/components/page-transition'
|
||||
import {
|
||||
AppsSection,
|
||||
CategorySections,
|
||||
MarketShareSection,
|
||||
ModelsSection,
|
||||
PulseSection,
|
||||
@ -26,7 +25,8 @@ export function Rankings() {
|
||||
? (search.period as RankingPeriod)
|
||||
: 'week'
|
||||
|
||||
const snapshot = useRankings(period)
|
||||
const rankingsQuery = useRankings(period)
|
||||
const snapshot = rankingsQuery.data?.data
|
||||
|
||||
const handlePeriodChange = (next: RankingPeriod) => {
|
||||
navigate({
|
||||
@ -56,37 +56,62 @@ export function Rankings() {
|
||||
<PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
|
||||
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
|
||||
|
||||
{/* Overall (all-categories) view ----------------------------- */}
|
||||
<ModelsSection
|
||||
history={snapshot.models_history}
|
||||
rows={snapshot.models}
|
||||
period={period}
|
||||
/>
|
||||
{rankingsQuery.isLoading ? (
|
||||
<RankingsLoading />
|
||||
) : !snapshot ? (
|
||||
<RankingsError
|
||||
message={
|
||||
rankingsQuery.error instanceof Error
|
||||
? rankingsQuery.error.message
|
||||
: t('Unable to load rankings data')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ModelsSection
|
||||
history={snapshot.models_history}
|
||||
rows={snapshot.models}
|
||||
period={period}
|
||||
/>
|
||||
|
||||
<MarketShareSection
|
||||
history={snapshot.vendor_share_history}
|
||||
rows={snapshot.vendors}
|
||||
period={period}
|
||||
/>
|
||||
<MarketShareSection
|
||||
history={snapshot.vendor_share_history}
|
||||
rows={snapshot.vendors}
|
||||
period={period}
|
||||
/>
|
||||
|
||||
<AppsSection rows={snapshot.apps} />
|
||||
|
||||
<PulseSection
|
||||
movers={snapshot.top_movers}
|
||||
droppers={snapshot.top_droppers}
|
||||
newModels={snapshot.new_models}
|
||||
/>
|
||||
|
||||
{/* Per-category drill-downs --------------------------------- */}
|
||||
<CategorySections sections={snapshot.category_sections} />
|
||||
|
||||
<p className='text-muted-foreground/60 mx-auto max-w-3xl text-center text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.'
|
||||
)}
|
||||
</p>
|
||||
<PulseSection
|
||||
movers={snapshot.top_movers}
|
||||
droppers={snapshot.top_droppers}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageTransition>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function RankingsLoading() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Skeleton className='h-[420px] w-full rounded-xl' />
|
||||
<Skeleton className='h-[360px] w-full rounded-xl' />
|
||||
<Skeleton className='h-[180px] w-full rounded-xl' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RankingsError(props: { message: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='bg-card rounded-xl border border-dashed px-6 py-12 text-center'>
|
||||
<h2 className='text-foreground text-base font-semibold'>
|
||||
{t('Unable to load rankings')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground mx-auto mt-2 max-w-md text-sm'>
|
||||
{props.message}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from './format'
|
||||
export * from './mock-rankings'
|
||||
|
||||
1048
web/default/src/features/rankings/lib/mock-rankings.ts
vendored
1048
web/default/src/features/rankings/lib/mock-rankings.ts
vendored
File diff suppressed because it is too large
Load Diff
81
web/default/src/features/rankings/types.ts
vendored
81
web/default/src/features/rankings/types.ts
vendored
@ -2,11 +2,7 @@
|
||||
// Rankings types
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// Shape of the data shown on the /rankings page. The backend has not yet
|
||||
// implemented these analytics endpoints, so the helpers in
|
||||
// `lib/mock-rankings.ts` produce deterministic mock values seeded from the
|
||||
// (period, category) tuple. When the real APIs land, these types double as
|
||||
// the response shape the UI expects.
|
||||
// Shape of the real data shown on the /rankings page.
|
||||
|
||||
export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
|
||||
|
||||
@ -24,13 +20,6 @@ export type RankingCategoryId =
|
||||
| 'productivity'
|
||||
| 'multimodal'
|
||||
|
||||
export type RankingCategory = {
|
||||
id: RankingCategoryId
|
||||
/** Default English label, fed through i18n at render time. */
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type ModelRanking = {
|
||||
rank: number
|
||||
/** Previous rank in the same period; undefined means "new". */
|
||||
@ -47,37 +36,6 @@ export type ModelRanking = {
|
||||
growth_pct: number
|
||||
}
|
||||
|
||||
export type AppCategory =
|
||||
| 'Coding'
|
||||
| 'Chat'
|
||||
| 'Productivity'
|
||||
| 'Education'
|
||||
| 'Creative'
|
||||
| 'Roleplay'
|
||||
| 'Translation'
|
||||
| 'Marketing'
|
||||
| 'Health'
|
||||
| 'Finance'
|
||||
| 'Research'
|
||||
| 'Other'
|
||||
|
||||
export type AppListing = {
|
||||
rank: number
|
||||
previous_rank?: number
|
||||
name: string
|
||||
description: string
|
||||
category: AppCategory
|
||||
url?: string
|
||||
/** Total tokens this app sent through new-api in the period. */
|
||||
total_tokens: number
|
||||
/** Period-over-period change. */
|
||||
growth_pct: number
|
||||
/** Top model used by this app (model_name). */
|
||||
top_model: string
|
||||
/** Logo letter / initial. */
|
||||
initial: string
|
||||
}
|
||||
|
||||
export type VendorRanking = {
|
||||
rank: number
|
||||
vendor: string
|
||||
@ -102,17 +60,6 @@ export type RankingMover = {
|
||||
growth_pct: number
|
||||
}
|
||||
|
||||
export type NewModelEntry = {
|
||||
model_name: string
|
||||
vendor: string
|
||||
vendor_icon?: string
|
||||
category: RankingCategoryId
|
||||
release_date: string
|
||||
total_tokens: number
|
||||
/** % growth since the model launched. */
|
||||
growth_pct: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One sample of a model's token usage at a given timestamp.
|
||||
* Flat shape ready to feed VChart's stacked-bar spec.
|
||||
@ -158,42 +105,16 @@ export type VendorShareSeries = {
|
||||
buckets: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained ranking unit for a single category. Pairs the small
|
||||
* stacked-bar chart with the leaderboard data it summarises so
|
||||
* `<CategorySection>` can render both halves from one prop. Every
|
||||
* category gets one of these rendered inline on the rankings page.
|
||||
*/
|
||||
export type CategorySection = {
|
||||
category: RankingCategoryId
|
||||
/** English source label, fed through i18n at render time. */
|
||||
label: string
|
||||
/** English source description, fed through i18n at render time. */
|
||||
description: string
|
||||
/** Top models in this category, ordered by total tokens desc. */
|
||||
models: ModelRanking[]
|
||||
/** Stacked-bar history of token usage by model in this category. */
|
||||
models_history: ModelHistorySeries
|
||||
/** Sum of all `models[].total_tokens` (cached for the section header). */
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
export type RankingsSnapshot = {
|
||||
// Overall (all categories) ------------------------------------------------
|
||||
models: ModelRanking[]
|
||||
apps: AppListing[]
|
||||
vendors: VendorRanking[]
|
||||
/** Largest rank gainers in this period. */
|
||||
top_movers: RankingMover[]
|
||||
/** Largest rank losers in this period. */
|
||||
top_droppers: RankingMover[]
|
||||
/** Newly launched / recently added models. */
|
||||
new_models: NewModelEntry[]
|
||||
/** Stacked-bar history of token usage by model over the period. */
|
||||
models_history: ModelHistorySeries
|
||||
/** 100%-stacked area history of token share by vendor over the period. */
|
||||
vendor_share_history: VendorShareSeries
|
||||
// Per-category sections ---------------------------------------------------
|
||||
/** Independent ranking sections, one per non-`all` category. */
|
||||
category_sections: CategorySection[]
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export type HeaderNavPricingConfig = {
|
||||
export type HeaderNavAccessConfig = {
|
||||
enabled: boolean
|
||||
requireAuth: boolean
|
||||
}
|
||||
@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = {
|
||||
export type HeaderNavModulesConfig = {
|
||||
home: boolean
|
||||
console: boolean
|
||||
pricing: HeaderNavPricingConfig
|
||||
pricing: HeaderNavAccessConfig
|
||||
rankings: HeaderNavAccessConfig
|
||||
docs: boolean
|
||||
about: boolean
|
||||
[key: string]: boolean | HeaderNavPricingConfig
|
||||
[key: string]: boolean | HeaderNavAccessConfig
|
||||
}
|
||||
|
||||
export type SidebarSectionConfig = {
|
||||
@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
rankings: {
|
||||
enabled: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
docs: true,
|
||||
about: true,
|
||||
}
|
||||
@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => {
|
||||
const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
|
||||
...HEADER_NAV_DEFAULT,
|
||||
pricing: { ...HEADER_NAV_DEFAULT.pricing },
|
||||
rankings: { ...HEADER_NAV_DEFAULT.rankings },
|
||||
})
|
||||
|
||||
const parseAccessModule = (
|
||||
raw: unknown,
|
||||
fallback: HeaderNavAccessConfig
|
||||
): HeaderNavAccessConfig => {
|
||||
if (
|
||||
typeof raw === 'boolean' ||
|
||||
typeof raw === 'string' ||
|
||||
typeof raw === 'number'
|
||||
) {
|
||||
return {
|
||||
enabled: toBoolean(raw, fallback.enabled),
|
||||
requireAuth: fallback.requireAuth,
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as Record<string, unknown>
|
||||
return {
|
||||
enabled: toBoolean(record.enabled, fallback.enabled),
|
||||
requireAuth: toBoolean(record.requireAuth, fallback.requireAuth),
|
||||
}
|
||||
}
|
||||
return { ...fallback }
|
||||
}
|
||||
|
||||
const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
|
||||
Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
|
||||
(acc, [section, config]) => {
|
||||
@ -97,23 +127,16 @@ export function parseHeaderNavModules(
|
||||
const result: HeaderNavModulesConfig = {
|
||||
...base,
|
||||
pricing: { ...base.pricing },
|
||||
rankings: { ...base.rankings },
|
||||
}
|
||||
|
||||
Object.entries(parsed).forEach(([key, raw]) => {
|
||||
if (key === 'pricing') {
|
||||
if (raw && typeof raw === 'object') {
|
||||
const rawPricing = raw as Record<string, unknown>
|
||||
result.pricing = {
|
||||
enabled: toBoolean(
|
||||
rawPricing.enabled,
|
||||
base.pricing?.enabled ?? true
|
||||
),
|
||||
requireAuth: toBoolean(
|
||||
rawPricing.requireAuth,
|
||||
base.pricing?.requireAuth ?? false
|
||||
),
|
||||
}
|
||||
}
|
||||
result.pricing = parseAccessModule(raw, base.pricing)
|
||||
return
|
||||
}
|
||||
if (key === 'rankings') {
|
||||
result.rankings = parseAccessModule(raw, base.rankings)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,8 @@ const headerNavSchema = z.object({
|
||||
console: z.boolean(),
|
||||
pricingEnabled: z.boolean(),
|
||||
pricingRequireAuth: z.boolean(),
|
||||
rankingsEnabled: z.boolean(),
|
||||
rankingsRequireAuth: z.boolean(),
|
||||
docs: z.boolean(),
|
||||
about: z.boolean(),
|
||||
})
|
||||
@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
|
||||
config.pricing?.requireAuth === undefined
|
||||
? HEADER_NAV_DEFAULT.pricing.requireAuth
|
||||
: Boolean(config.pricing.requireAuth),
|
||||
rankingsEnabled:
|
||||
config.rankings?.enabled === undefined
|
||||
? HEADER_NAV_DEFAULT.rankings.enabled
|
||||
: Boolean(config.rankings.enabled),
|
||||
rankingsRequireAuth:
|
||||
config.rankings?.requireAuth === undefined
|
||||
? HEADER_NAV_DEFAULT.rankings.requireAuth
|
||||
: Boolean(config.rankings.requireAuth),
|
||||
docs:
|
||||
config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
|
||||
about:
|
||||
@ -90,6 +100,11 @@ export function HeaderNavigationSection({
|
||||
enabled: values.pricingEnabled,
|
||||
requireAuth: values.pricingRequireAuth,
|
||||
},
|
||||
rankings: {
|
||||
...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
|
||||
enabled: values.rankingsEnabled,
|
||||
requireAuth: values.rankingsRequireAuth,
|
||||
},
|
||||
}
|
||||
|
||||
const serialized = serializeHeaderNavModules(payload)
|
||||
@ -107,7 +122,7 @@ export function HeaderNavigationSection({
|
||||
form.reset(toFormValues(HEADER_NAV_DEFAULT))
|
||||
}
|
||||
|
||||
const modules: Array<{
|
||||
const simpleModules: Array<{
|
||||
key: keyof HeaderNavFormValues
|
||||
title: string
|
||||
description: string
|
||||
@ -134,6 +149,39 @@ export function HeaderNavigationSection({
|
||||
},
|
||||
]
|
||||
|
||||
const accessModules: Array<{
|
||||
enabledKey: keyof HeaderNavFormValues
|
||||
requireAuthKey: keyof HeaderNavFormValues
|
||||
requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled'
|
||||
title: string
|
||||
description: string
|
||||
requireAuthTitle: string
|
||||
requireAuthDescription: string
|
||||
}> = [
|
||||
{
|
||||
enabledKey: 'pricingEnabled',
|
||||
requireAuthKey: 'pricingRequireAuth',
|
||||
requireAuthDependsOn: 'pricingEnabled',
|
||||
title: t('Model Square'),
|
||||
description: t('Public model catalog and pricing page.'),
|
||||
requireAuthTitle: t('Require login to view models'),
|
||||
requireAuthDescription: t(
|
||||
'Visitors must authenticate before accessing the pricing directory.'
|
||||
),
|
||||
},
|
||||
{
|
||||
enabledKey: 'rankingsEnabled',
|
||||
requireAuthKey: 'rankingsRequireAuth',
|
||||
requireAuthDependsOn: 'rankingsEnabled',
|
||||
title: t('Rankings'),
|
||||
description: t('Public rankings page based on live usage data.'),
|
||||
requireAuthTitle: t('Require login to view rankings'),
|
||||
requireAuthDescription: t(
|
||||
'Visitors must authenticate before accessing the rankings page.'
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Header navigation')}
|
||||
@ -142,7 +190,7 @@ export function HeaderNavigationSection({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
{modules.map((module) => (
|
||||
{simpleModules.map((module) => (
|
||||
<FormField
|
||||
key={module.key}
|
||||
control={form.control}
|
||||
@ -168,59 +216,57 @@ export function HeaderNavigationSection({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pricingEnabled'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Models directory')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Exposes the pricing/models catalog in the top navigation.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
{accessModules.map((module) => (
|
||||
<div key={module.enabledKey} className='rounded-lg border p-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={module.enabledKey}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{module.title}
|
||||
</FormLabel>
|
||||
<FormDescription>{module.description}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='pricingRequireAuth'
|
||||
render={({ field }) => (
|
||||
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{t('Require login to view models')}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
'Visitors must authenticate before accessing the pricing directory.'
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!form.watch('pricingEnabled')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={module.requireAuthKey}
|
||||
render={({ field }) => (
|
||||
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
|
||||
<div className='space-y-0.5 pe-4'>
|
||||
<FormLabel className='text-base'>
|
||||
{module.requireAuthTitle}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{module.requireAuthDescription}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!form.watch(module.requireAuthDependsOn)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
|
||||
@ -269,11 +269,6 @@ function getModeBadgeVariant(
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
function truncateExpr(value: string) {
|
||||
if (!value) return ''
|
||||
return value.length > 110 ? `${value.slice(0, 110)}...` : value
|
||||
}
|
||||
|
||||
function buildPreviewRows(
|
||||
values: ModelPricingFormValues,
|
||||
mode: PricingMode,
|
||||
|
||||
@ -2,9 +2,16 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants'
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
import { getDefaultTimeRange } from '../lib/utils'
|
||||
import type { CommonLogFilters } from '../types'
|
||||
import { CommonLogsStats } from './common-logs-stats'
|
||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
const queryClient = useQueryClient()
|
||||
const searchParams = route.useSearch()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
|
||||
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
|
||||
|
||||
const [filters, setFilters] = useState<CommonLogFilters>(() => {
|
||||
@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>(
|
||||
const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
|
||||
const sensitiveType = sensitiveVisible ? 'text' : 'password'
|
||||
|
||||
const statsBar = (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<CommonLogsStats />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground size-7'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? <Eye /> : <EyeOff />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sensitiveVisible ? t('Hide') : t('Show')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={props.table}
|
||||
leftActions={statsBar}
|
||||
customSearch={
|
||||
<CompactDateTimeRangePicker
|
||||
start={filters.startTime}
|
||||
|
||||
@ -6,7 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import type { NavGroup } from '@/components/layout/types'
|
||||
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
|
||||
import { CommonLogsHeaderActions } from './components/common-logs-header-actions'
|
||||
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
|
||||
import {
|
||||
UsageLogsProvider,
|
||||
@ -106,11 +105,6 @@ function UsageLogsContent() {
|
||||
<SectionPageLayout.Description>
|
||||
{t(pageMeta.descriptionKey)}
|
||||
</SectionPageLayout.Description>
|
||||
{activeCategory === 'common' && (
|
||||
<SectionPageLayout.Actions>
|
||||
<CommonLogsHeaderActions />
|
||||
</SectionPageLayout.Actions>
|
||||
)}
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
{showTaskSwitcher && (
|
||||
|
||||
66
web/default/src/hooks/use-top-nav-links.ts
vendored
66
web/default/src/hooks/use-top-nav-links.ts
vendored
@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = {
|
||||
about: true,
|
||||
}
|
||||
|
||||
function parseAccessModule(
|
||||
raw: unknown,
|
||||
fallback: { enabled: boolean; requireAuth: boolean }
|
||||
) {
|
||||
if (
|
||||
typeof raw === 'boolean' ||
|
||||
typeof raw === 'string' ||
|
||||
typeof raw === 'number'
|
||||
) {
|
||||
return {
|
||||
enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
|
||||
requireAuth: fallback.requireAuth,
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as Record<string, unknown>
|
||||
return {
|
||||
enabled:
|
||||
typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled,
|
||||
requireAuth:
|
||||
typeof record.requireAuth === 'boolean'
|
||||
? record.requireAuth
|
||||
: fallback.requireAuth,
|
||||
}
|
||||
}
|
||||
return { ...fallback }
|
||||
}
|
||||
|
||||
function parseHeaderNavModules(
|
||||
raw: unknown
|
||||
): typeof DEFAULT_HEADER_NAV_MODULES {
|
||||
if (!raw || String(raw).trim() === '') {
|
||||
return DEFAULT_HEADER_NAV_MODULES
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw)) as Record<string, unknown>
|
||||
return {
|
||||
...DEFAULT_HEADER_NAV_MODULES,
|
||||
...parsed,
|
||||
pricing: parseAccessModule(
|
||||
parsed.pricing,
|
||||
DEFAULT_HEADER_NAV_MODULES.pricing
|
||||
),
|
||||
rankings: parseAccessModule(
|
||||
parsed.rankings,
|
||||
DEFAULT_HEADER_NAV_MODULES.rankings
|
||||
),
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_HEADER_NAV_MODULES
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status
|
||||
* Backend format example (stringified JSON):
|
||||
@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = {
|
||||
* home: true,
|
||||
* console: true,
|
||||
* pricing: { enabled: true, requireAuth: false },
|
||||
* rankings: { enabled: true, requireAuth: false },
|
||||
* docs: true,
|
||||
* about: true
|
||||
* }
|
||||
@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] {
|
||||
|
||||
// Parse HeaderNavModules
|
||||
const modules = useMemo(() => {
|
||||
const raw = status?.HeaderNavModules
|
||||
// If empty string, null, or undefined, use default config
|
||||
if (!raw || (raw as string).trim() === '') {
|
||||
return DEFAULT_HEADER_NAV_MODULES
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw as string)
|
||||
} catch {
|
||||
// Parse failed, use default config
|
||||
return DEFAULT_HEADER_NAV_MODULES
|
||||
}
|
||||
return parseHeaderNavModules(status?.HeaderNavModules)
|
||||
}, [status?.HeaderNavModules])
|
||||
|
||||
// Documentation link (may be external)
|
||||
|
||||
4
web/default/src/i18n/locales/en.json
vendored
4
web/default/src/i18n/locales/en.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "Provide Markdown, HTML, or an external URL for the user agreement",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.",
|
||||
"Public model catalog and pricing page.": "Public model catalog and pricing page.",
|
||||
"Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
|
||||
"Provider": "Provider",
|
||||
"Provider & data privacy": "Provider & data privacy",
|
||||
"Provider created successfully": "Provider created successfully",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "Require email verification for new accounts",
|
||||
"Require job success before follow-up actions": "Require job success before follow-up actions",
|
||||
"Require login to view models": "Require login to view models",
|
||||
"Require login to view rankings": "Require login to view rankings",
|
||||
"required": "required",
|
||||
"Required": "Required",
|
||||
"Required events:": "Required events:",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "Vision, image / video, document chat",
|
||||
"Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.",
|
||||
"Visitors must authenticate before accessing the rankings page.": "Visitors must authenticate before accessing the rankings page.",
|
||||
"Visual": "Visual",
|
||||
"Visual edit": "Visual edit",
|
||||
"Visual editor": "Visual editor",
|
||||
|
||||
4
web/default/src/i18n/locales/fr.json
vendored
4
web/default/src/i18n/locales/fr.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "Fournir du Markdown, du HTML ou une URL externe pour l'accord utilisateur",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Fournir des remplacements de sécurité par catégorie au format JSON. Utilisez `default` pour les valeurs de secours.",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Fournir des remplacements d'en-tête par modèle au format JSON. Utile pour activer des fonctionnalités bêta telles que les fenêtres de contexte étendues.",
|
||||
"Public model catalog and pricing page.": "Page publique du catalogue des modèles et des tarifs.",
|
||||
"Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.",
|
||||
"Provider": "Fournisseur",
|
||||
"Provider & data privacy": "Fournisseur & confidentialité",
|
||||
"Provider created successfully": "Fournisseur créé avec succès",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes",
|
||||
"Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi",
|
||||
"Require login to view models": "Exiger la connexion pour voir les modèles",
|
||||
"Require login to view rankings": "Exiger la connexion pour voir les classements",
|
||||
"required": "requis",
|
||||
"Required": "Requis",
|
||||
"Required events:": "Événements requis :",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document",
|
||||
"Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "Les visiteurs doivent s'authentifier avant d'accéder au répertoire des prix.",
|
||||
"Visitors must authenticate before accessing the rankings page.": "Les visiteurs doivent s'authentifier avant d'accéder à la page des classements.",
|
||||
"Visual": "Visuel",
|
||||
"Visual edit": "Édition visuelle",
|
||||
"Visual editor": "Éditeur visuel",
|
||||
|
||||
4
web/default/src/i18n/locales/ja.json
vendored
4
web/default/src/i18n/locales/ja.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "ユーザー同意書にMarkdown、HTML、または外部URLを提供する",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "カテゴリごとの安全オーバーライドをJSONとして提供します。フォールバック値には`default`を使用してください。",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "モデルごとのヘッダーオーバーライドをJSONとして提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。",
|
||||
"Public model catalog and pricing page.": "モデルカタログと料金の公開ページ。",
|
||||
"Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。",
|
||||
"Provider": "プロバイダ",
|
||||
"Provider & data privacy": "プロバイダーとデータ保護",
|
||||
"Provider created successfully": "プロバイダーの作成に成功しました",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "新しいアカウントにメール認証を要求する",
|
||||
"Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求",
|
||||
"Require login to view models": "モデルを表示するにはログインを要求する",
|
||||
"Require login to view rankings": "ランキングを表示するにはログインを要求する",
|
||||
"required": "必須",
|
||||
"Required": "必須",
|
||||
"Required events:": "必須イベント:",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット",
|
||||
"Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "訪問者は料金ディレクトリにアクセスする前に認証を行う必要があります。",
|
||||
"Visitors must authenticate before accessing the rankings page.": "訪問者はランキングページにアクセスする前に認証を行う必要があります。",
|
||||
"Visual": "ビジュアル",
|
||||
"Visual edit": "ビジュアル編集",
|
||||
"Visual editor": "ビジュアルエディター",
|
||||
|
||||
4
web/default/src/i18n/locales/ru.json
vendored
4
web/default/src/i18n/locales/ru.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "Укажите Markdown, HTML или внешний URL для пользовательского соглашения",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Предоставьте переопределения безопасности по категориям в формате JSON. Используйте `default` для резервных значений.",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Предоставьте переопределения заголовков для каждой модели в формате JSON. Полезно для включения бета-функций, таких как расширенные окна контекста.",
|
||||
"Public model catalog and pricing page.": "Публичная страница каталога моделей и цен.",
|
||||
"Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.",
|
||||
"Provider": "Провайдер",
|
||||
"Provider & data privacy": "Поставщик и конфиденциальность",
|
||||
"Provider created successfully": "Поставщик успешно создан",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей",
|
||||
"Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями",
|
||||
"Require login to view models": "Требовать вход для просмотра моделей",
|
||||
"Require login to view rankings": "Требовать вход для просмотра рейтингов",
|
||||
"required": "обязателен",
|
||||
"Required": "Обязательно",
|
||||
"Required events:": "Обязательные события:",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам",
|
||||
"Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.",
|
||||
"Visitors must authenticate before accessing the rankings page.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.",
|
||||
"Visual": "Визуальный",
|
||||
"Visual edit": "Визуальное редактирование",
|
||||
"Visual editor": "Визуальный редактор",
|
||||
|
||||
4
web/default/src/i18n/locales/vi.json
vendored
4
web/default/src/i18n/locales/vi.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "Cung cấp Markdown, HTML, hoặc một URL bên ngoài cho thỏa thuận người dùng",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Cung cấp các ghi đè an toàn theo từng danh mục dưới dạng JSON. Sử dụng `default` cho các giá trị dự phòng.",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Cung cấp các ghi đè tiêu đề theo từng mô hình dưới dạng JSON. Hữu ích để bật các tính năng beta như cửa sổ ngữ cảnh mở rộng.",
|
||||
"Public model catalog and pricing page.": "Trang công khai cho danh mục mô hình và giá.",
|
||||
"Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.",
|
||||
"Provider": "Nhà cung cấp",
|
||||
"Provider & data privacy": "Nhà cung cấp & quyền riêng tư",
|
||||
"Provider created successfully": "Đã tạo nhà cung cấp thành công",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới",
|
||||
"Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo",
|
||||
"Require login to view models": "Yêu cầu đăng nhập để xem các mô hình",
|
||||
"Require login to view rankings": "Yêu cầu đăng nhập để xem bảng xếp hạng",
|
||||
"required": "bắt buộc",
|
||||
"Required": "Bắt buộc",
|
||||
"Required events:": "Sự kiện bắt buộc:",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu",
|
||||
"Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "Khách truy cập phải xác thực trước khi truy cập thư mục giá.",
|
||||
"Visitors must authenticate before accessing the rankings page.": "Khách truy cập phải xác thực trước khi truy cập trang bảng xếp hạng.",
|
||||
"Visual": "Trực quan",
|
||||
"Visual edit": "Chỉnh sửa trực quan",
|
||||
"Visual editor": "Trình sửa trực quan",
|
||||
|
||||
4
web/default/src/i18n/locales/zh.json
vendored
4
web/default/src/i18n/locales/zh.json
vendored
@ -2943,6 +2943,8 @@
|
||||
"Provide Markdown, HTML, or an external URL for the user agreement": "提供 Markdown、HTML 或外部 URL 作为用户协议",
|
||||
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "以 JSON 格式提供按类别划分的安全覆盖。使用 `default` 作为回退值。",
|
||||
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "以 JSON 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。",
|
||||
"Public model catalog and pricing page.": "公开模型目录和价格页面。",
|
||||
"Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。",
|
||||
"Provider": "提供商",
|
||||
"Provider & data privacy": "厂商与数据隐私",
|
||||
"Provider created successfully": "提供商创建成功",
|
||||
@ -3157,6 +3159,7 @@
|
||||
"Require email verification for new accounts": "要求新账户验证邮箱",
|
||||
"Require job success before follow-up actions": "在后续操作前要求任务成功",
|
||||
"Require login to view models": "要求登录才能查看模型",
|
||||
"Require login to view rankings": "要求登录才能查看排行榜",
|
||||
"required": "必填",
|
||||
"Required": "必需",
|
||||
"Required events:": "必需事件:",
|
||||
@ -4125,6 +4128,7 @@
|
||||
"Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话",
|
||||
"Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...",
|
||||
"Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。",
|
||||
"Visitors must authenticate before accessing the rankings page.": "访客必须先进行身份验证才能访问排行榜页面。",
|
||||
"Visual": "可视",
|
||||
"Visual edit": "可视化编辑",
|
||||
"Visual editor": "可视化编辑器",
|
||||
|
||||
3
web/default/src/i18n/static-keys.ts
vendored
3
web/default/src/i18n/static-keys.ts
vendored
@ -4,7 +4,8 @@ export const STATIC_I18N_KEYS = [
|
||||
// Header navigation
|
||||
'Home',
|
||||
'Console',
|
||||
'Pricing',
|
||||
'Model Square',
|
||||
'Rankings',
|
||||
'Docs',
|
||||
'About',
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user