Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c29d80f015 | |||
| 3ba01a7dcd | |||
| 20d3e73734 | |||
| 2d1ca15384 | |||
| 0d4b25795a | |||
| 146dd77b83 | |||
| 5e88f97ac1 | |||
| 0cd9a3a068 | |||
| 032993ed49 | |||
| c78573ce03 | |||
| 8db32213e7 | |||
| cb9270ed23 | |||
| fc08c133e2 | |||
| b397c58bab | |||
| 8ae095c3b8 | |||
| 04b4483d7d | |||
| ee9736bbc8 | |||
| 0936e25046 | |||
| 5dd0d3bcbd | |||
| f69ceb6967 | |||
| 68830e6097 | |||
| 2d968c3eab | |||
| cb7a61466e | |||
| 132d7b9f94 | |||
| 6f8668e4c3 | |||
| 8a10dedb7d | |||
| 554defe4f4 | |||
| 8f9ee9ba88 | |||
| 3caa6e467b | |||
| faa0f1425a |
+63
-39
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OpenAIModel struct {
|
||||
@@ -68,12 +69,33 @@ func clearChannelInfo(channel *model.Channel) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
return query.Where("status = ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
if statusFilter == 0 {
|
||||
return query.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB {
|
||||
query := model.DB.Model(&model.Channel{})
|
||||
query = model.ApplyChannelGroupFilter(query, group)
|
||||
query = applyChannelStatusFilter(query, statusFilter)
|
||||
if typeFilter >= 0 {
|
||||
query = query.Where("type = ?", typeFilter)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
groupFilter := model.NormalizeChannelGroupFilter(c.Query("group"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
@@ -89,50 +111,45 @@ func GetAllChannels(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.SysError("failed to get paginated tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
|
||||
if err != nil {
|
||||
common.SysError("failed to count tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
continue
|
||||
common.SysError("failed to get channels by tag: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
filtered := make([]*model.Channel, 0)
|
||||
for _, ch := range tagChannels {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if typeFilter >= 0 && ch.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = append(channelData, filtered...)
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
total, _ = model.CountAllTags()
|
||||
} else {
|
||||
baseQuery := model.DB.Model(&model.Channel{})
|
||||
if typeFilter >= 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", typeFilter)
|
||||
}
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
|
||||
common.SysError("failed to count channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)).
|
||||
Limit(pageInfo.GetPageSize()).
|
||||
Offset(pageInfo.GetStartIdx()).
|
||||
Omit("key").
|
||||
Find(&channelData).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
@@ -144,17 +161,16 @@ func GetAllChannels(c *gin.Context) {
|
||||
clearChannelInfo(datum)
|
||||
}
|
||||
|
||||
countQuery := model.DB.Model(&model.Channel{})
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
countQuery := buildChannelListQuery(groupFilter, statusFilter, -1)
|
||||
var results []struct {
|
||||
Type int64
|
||||
Count int64
|
||||
}
|
||||
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
|
||||
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
|
||||
common.SysError("failed to count channel types: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, r := range results {
|
||||
typeCounts[r.Type] = r.Count
|
||||
@@ -262,10 +278,18 @@ func SearchChannels(c *gin.Context) {
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
@@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ func GetStatus(c *gin.Context) {
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
|
||||
@@ -350,6 +350,11 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GetPerfMetricsSummary(c *gin.Context) {
|
||||
@@ -18,7 +19,8 @@ func GetPerfMetricsSummary(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := perfmetrics.QuerySummaryAll(hours)
|
||||
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
|
||||
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -72,12 +74,9 @@ func GetPerfMetrics(c *gin.Context) {
|
||||
}
|
||||
|
||||
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
|
||||
activeGroups := ratio_setting.GetGroupRatioCopy()
|
||||
filtered := make([]perfmetrics.GroupResult, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if _, ok := activeGroups[g.Group]; ok || g.Group == "auto" {
|
||||
filtered = append(filtered, g)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
activeRatios := ratio_setting.GetGroupRatioCopy()
|
||||
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
|
||||
_, ok := activeRatios[g.Group]
|
||||
return ok || g.Group == "auto"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,51 +3,11 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func isRankingsEnabled() bool {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if raw == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return true
|
||||
}
|
||||
rankings, ok := parsed["rankings"]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch v := rankings.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
if enabled, ok := v["enabled"]; ok {
|
||||
if b, ok := enabled.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetRankings(c *gin.Context) {
|
||||
if !isRankingsEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "rankings is disabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
|
||||
@@ -96,13 +96,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask response: %s", responseBody)
|
||||
|
||||
taskResult := &relaycommon.TaskInfo{}
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
@@ -116,7 +116,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask taskResult: %+v", taskResult)
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
|
||||
+1
-1
@@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权操作同级或更高级用户的2FA设置",
|
||||
|
||||
+15
-9
@@ -264,6 +264,10 @@ func SearchUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func canManageTargetRole(myRole int, targetRole int) bool {
|
||||
return myRole == common.RoleRootUser || myRole > targetRole
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -276,7 +280,7 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -567,11 +571,11 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, updatedUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
@@ -610,7 +614,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -778,12 +782,14 @@ func DeleteUser(c *gin.Context) {
|
||||
}
|
||||
err = model.HardDeleteUserById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
@@ -872,7 +878,7 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
|
||||
+9
-4
@@ -95,9 +95,11 @@ func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
}
|
||||
|
||||
func logHelper(ctx context.Context, level string, msg string) {
|
||||
id := ctx.Value(common.RequestIdKey)
|
||||
if id == nil {
|
||||
id = "SYSTEM"
|
||||
var id any = "SYSTEM"
|
||||
if ctx != nil {
|
||||
if requestID := ctx.Value(common.RequestIdKey); requestID != nil {
|
||||
id = requestID
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
common.LogWriterMu.RLock()
|
||||
@@ -172,10 +174,13 @@ func FormatQuota(quota int) string {
|
||||
|
||||
// LogJson 仅供测试使用 only for test
|
||||
func LogJson(ctx context.Context, msg string, obj any) {
|
||||
if !common.DebugEnabled {
|
||||
return
|
||||
}
|
||||
jsonStr, err := common.Marshal(obj)
|
||||
if err != nil {
|
||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
|
||||
LogDebug(ctx, "%s | %s", msg, jsonStr)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
DEV_POSTGRES_DB = new-api
|
||||
DEV_POSTGRES_USER = root
|
||||
DEV_SQLITE_PATH ?= one-api.db
|
||||
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
|
||||
|
||||
all: build-all-frontends start-backend
|
||||
|
||||
@@ -22,7 +28,11 @@ start-backend:
|
||||
|
||||
dev-api:
|
||||
@echo "Starting backend services (docker)..."
|
||||
@docker compose -f docker-compose.dev.yml up -d
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d
|
||||
|
||||
dev-api-rebuild:
|
||||
@echo "Rebuilding and starting backend service (docker)..."
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@@ -33,3 +43,27 @@ dev-web-classic:
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
reset-setup:
|
||||
@echo "Resetting local setup wizard state..."
|
||||
@if docker compose -f $(DEV_COMPOSE_FILE) ps --services --status running | grep -qx "$(DEV_POSTGRES_SERVICE)"; then \
|
||||
echo "Detected running docker dev PostgreSQL. Removing setup record and root users..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) exec -T $(DEV_POSTGRES_SERVICE) \
|
||||
psql -U $(DEV_POSTGRES_USER) -d $(DEV_POSTGRES_DB) \
|
||||
-c 'DELETE FROM setups;' \
|
||||
-c 'DELETE FROM users WHERE role = 100;' \
|
||||
-c "DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "Restarting docker dev backend so setup status is recalculated..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) restart $(DEV_BACKEND_SERVICE); \
|
||||
elif db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; db_path="$${db_path%%\?*}"; [ -f "$$db_path" ]; then \
|
||||
db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; \
|
||||
db_path="$${db_path%%\?*}"; \
|
||||
echo "Detected local SQLite database: $$db_path"; \
|
||||
sqlite3 "$$db_path" \
|
||||
"DELETE FROM setups; DELETE FROM users WHERE role = 100; DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "SQLite setup state reset. Restart the local backend process before testing the setup wizard."; \
|
||||
else \
|
||||
echo "No running docker dev PostgreSQL or local SQLite database found."; \
|
||||
echo "Start the dev stack with 'make dev-api', or set SQLITE_PATH/DEV_SQLITE_PATH to your local SQLite database."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type headerNavAccess struct {
|
||||
Enabled bool
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
func getHeaderNavAccess(module string) headerNavAccess {
|
||||
fallback := headerNavAccess{
|
||||
Enabled: true,
|
||||
RequireAuth: false,
|
||||
}
|
||||
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parseHeaderNavAccess(parsed[module], fallback)
|
||||
}
|
||||
|
||||
func parseHeaderNavAccess(raw any, fallback headerNavAccess) headerNavAccess {
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return headerNavAccess{
|
||||
Enabled: value,
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case string:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case float64:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case map[string]any:
|
||||
access := fallback
|
||||
if enabled, ok := value["enabled"]; ok {
|
||||
access.Enabled = parseHeaderNavBool(enabled, fallback.Enabled)
|
||||
}
|
||||
if requireAuth, ok := value["requireAuth"]; ok {
|
||||
access.RequireAuth = parseHeaderNavBool(requireAuth, fallback.RequireAuth)
|
||||
}
|
||||
return access
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func parseHeaderNavBool(value any, fallback bool) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
case float64:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
case int:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModuleAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("%s is disabled", module),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled || access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func withHeaderNavModules(t *testing.T, raw string) {
|
||||
t.Helper()
|
||||
|
||||
common.OptionMapRWMutex.Lock()
|
||||
if common.OptionMap == nil {
|
||||
common.OptionMap = map[string]string{}
|
||||
}
|
||||
previous, hadPrevious := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMap["HeaderNavModules"] = raw
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
defer common.OptionMapRWMutex.Unlock()
|
||||
if hadPrevious {
|
||||
common.OptionMap["HeaderNavModules"] = previous
|
||||
return
|
||||
}
|
||||
delete(common.OptionMap, "HeaderNavModules")
|
||||
})
|
||||
}
|
||||
|
||||
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
|
||||
router.GET("/login", func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("username", "tester")
|
||||
session.Set("role", common.RoleCommonUser)
|
||||
session.Set("id", 1)
|
||||
session.Set("status", common.UserStatusEnabled)
|
||||
session.Set("group", "default")
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
router.GET("/api/test", handler, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
})
|
||||
|
||||
var cookies []*http.Cookie
|
||||
if authenticated {
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
|
||||
cookies = loginRecorder.Result().Cookies()
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
if authenticated {
|
||||
request.Header.Set("New-Api-User", "1")
|
||||
for _, cookie := range cookies {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsDisabledPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) {
|
||||
raw := `{"rankings":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"rankings":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsLoggedInWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), true)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenRequireAuth(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"pricing":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
+91
-40
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/samber/lo"
|
||||
@@ -128,6 +129,38 @@ func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) Ch
|
||||
return options
|
||||
}
|
||||
|
||||
func NormalizeChannelGroupFilter(group string) string {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" || strings.EqualFold(group, "all") || strings.EqualFold(group, "null") {
|
||||
return ""
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func channelGroupFilterCondition() string {
|
||||
if common.UsingMySQL {
|
||||
return `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
return `(',' || ` + commonGroupCol + ` || ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
|
||||
func channelGroupFilterPattern(group string) string {
|
||||
group = strings.NewReplacer(
|
||||
"!", "!!",
|
||||
"%", "!%",
|
||||
"_", "!_",
|
||||
).Replace(group)
|
||||
return "%," + group + ",%"
|
||||
}
|
||||
|
||||
func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
|
||||
group = NormalizeChannelGroupFilter(group)
|
||||
if group == "" {
|
||||
return query
|
||||
}
|
||||
return query.Where(channelGroupFilterCondition(), channelGroupFilterPattern(group))
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (c ChannelInfo) Value() (driver.Value, error) {
|
||||
return common.Marshal(&c)
|
||||
@@ -218,10 +251,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
if err != nil {
|
||||
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
defer func() {
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
|
||||
logger.LogDebug(nil, "channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
if !common.MemoryCacheEnabled {
|
||||
_ = channel.SaveChannelInfo()
|
||||
@@ -365,25 +397,12 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
// 执行查询
|
||||
err := order.Apply(baseQuery.Where(whereClause, args...)).Find(&channels).Error
|
||||
err := order.Apply(baseQuery).Find(&channels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -624,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
if len(keys) == 0 {
|
||||
channel.Status = status
|
||||
} else {
|
||||
var keyIndex int
|
||||
keyIndex := -1
|
||||
for i, key := range keys {
|
||||
if key == usingKey {
|
||||
keyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyIndex < 0 {
|
||||
if usingKey != "" {
|
||||
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
|
||||
return
|
||||
}
|
||||
channel.Status = status
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = reason
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.MultiKeyStatusList == nil {
|
||||
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||
}
|
||||
@@ -647,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
|
||||
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
|
||||
}
|
||||
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
|
||||
channel.Status = common.ChannelStatusAutoDisabled
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = "All keys are disabled"
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
} else if status == common.ChannelStatusEnabled {
|
||||
channel.Status = common.ChannelStatusEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
|
||||
for i := range keys {
|
||||
if statusList == nil {
|
||||
return true
|
||||
}
|
||||
status, ok := statusList[i]
|
||||
if !ok || status == common.ChannelStatusEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
|
||||
if common.MemoryCacheEnabled {
|
||||
channelStatusLock.Lock()
|
||||
@@ -668,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
beforeStatus := channelCache.Status
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channelCache.Status {
|
||||
CacheUpdateChannelStatus(channelId, channelCache.Status)
|
||||
}
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
@@ -828,8 +878,18 @@ func DeleteDisabledChannel() (int64, error) {
|
||||
}
|
||||
|
||||
func GetPaginatedTags(offset int, limit int) ([]*string, error) {
|
||||
return GetPaginatedChannelTags(DB.Model(&Channel{}), offset, limit)
|
||||
}
|
||||
|
||||
func GetPaginatedChannelTags(query *gorm.DB, offset int, limit int) ([]*string, error) {
|
||||
var tags []*string
|
||||
err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
|
||||
err := query.
|
||||
Select("DISTINCT tag").
|
||||
Where("tag is not null AND tag != ''").
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Name: "tag"}}).
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
@@ -857,24 +917,11 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
subQuery := baseQuery.Where(whereClause, args...).
|
||||
subQuery := baseQuery.
|
||||
Select("tag").
|
||||
Where("tag != ''").
|
||||
Order(order)
|
||||
@@ -1015,8 +1062,12 @@ func CountAllChannels() (int64, error) {
|
||||
|
||||
// CountAllTags returns number of non-empty distinct tags
|
||||
func CountAllTags() (int64, error) {
|
||||
return CountChannelTags(DB.Model(&Channel{}))
|
||||
}
|
||||
|
||||
func CountChannelTags(query *gorm.DB) (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
err := query.Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
)
|
||||
|
||||
@@ -257,9 +258,12 @@ func CacheUpdateChannel(channel *Channel) {
|
||||
return
|
||||
}
|
||||
|
||||
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
|
||||
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
if channelsIDM == nil {
|
||||
channelsIDM = make(map[int]*Channel)
|
||||
}
|
||||
if oldChannel, ok := channelsIDM[channel.Id]; ok {
|
||||
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
channelsIDM[channel.Id] = channel
|
||||
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
|
||||
+30
-35
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -308,15 +309,9 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
tx = tx.Where("logs.model_name like ?", modelName)
|
||||
}
|
||||
if username != "" {
|
||||
tx = tx.Where("logs.username = ?", username)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.username", username)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
@@ -397,16 +392,8 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
@@ -443,34 +430,42 @@ type Stat struct {
|
||||
Tpm int `json:"tpm"`
|
||||
}
|
||||
|
||||
func logContainsPattern(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_")
|
||||
return "%" + replacer.Replace(input) + "%", true
|
||||
}
|
||||
|
||||
func applyLogContainsFilter(tx *gorm.DB, column string, value string) *gorm.DB {
|
||||
pattern, ok := logContainsPattern(value)
|
||||
if !ok {
|
||||
return tx
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern)
|
||||
}
|
||||
|
||||
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
|
||||
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
|
||||
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
if username != "" {
|
||||
tx = tx.Where("username = ?", username)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "username", username)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "username", username)
|
||||
tx = applyLogContainsFilter(tx, "token_name", tokenName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "token_name", tokenName)
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("created_at >= ?", startTimestamp)
|
||||
}
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "model_name", modelName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "model_name", modelName)
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
|
||||
|
||||
+17
-10
@@ -37,13 +37,13 @@ func UpsertPerfMetric(metric *PerfMetric) error {
|
||||
{Name: "bucket_ts"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("success_count + ?", metric.SuccessCount),
|
||||
"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),
|
||||
"request_count": gorm.Expr("perf_metrics.request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("perf_metrics.success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("perf_metrics.total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("perf_metrics.ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("perf_metrics.ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("perf_metrics.output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("perf_metrics.generation_ms + ?", metric.GenerationMs),
|
||||
}),
|
||||
}).Create(metric).Error
|
||||
}
|
||||
@@ -68,11 +68,18 @@ type PerfMetricSummary struct {
|
||||
GenerationMs int64 `json:"generation_ms"`
|
||||
}
|
||||
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64) ([]PerfMetricSummary, error) {
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) {
|
||||
var summaries []PerfMetricSummary
|
||||
err := DB.Model(&PerfMetric{}).
|
||||
query := DB.Model(&PerfMetric{}).
|
||||
Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs).
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
|
||||
if groups != nil {
|
||||
if len(groups) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
query = query.Where(commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
err := query.
|
||||
Group("model_name").
|
||||
Having("SUM(request_count) > 0").
|
||||
Find(&summaries).Error
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestMain(m *testing.M) {
|
||||
common.RedisEnabled = false
|
||||
common.BatchUpdateEnabled = false
|
||||
common.LogConsumeEnabled = true
|
||||
initCol()
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
@@ -43,6 +44,7 @@ func TestMain(m *testing.M) {
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&PerfMetric{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func truncateTables(t *testing.T) {
|
||||
DB.Exec("DELETE FROM subscription_orders")
|
||||
DB.Exec("DELETE FROM subscription_plans")
|
||||
DB.Exec("DELETE FROM user_subscriptions")
|
||||
DB.Exec("DELETE FROM perf_metrics")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -35,8 +35,8 @@ type User struct {
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
|
||||
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
|
||||
|
||||
@@ -122,7 +122,7 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
return buildQueryResult(params.Model, merged), nil
|
||||
}
|
||||
|
||||
func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
@@ -131,8 +131,9 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
}
|
||||
endTs := time.Now().Unix()
|
||||
startTs := endTs - int64(hours)*3600
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs)
|
||||
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs, groups)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
@@ -153,6 +154,11 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
if k.bucketTs < startTs || k.bucketTs > endTs {
|
||||
return true
|
||||
}
|
||||
if allowedGroups != nil {
|
||||
if _, ok := allowedGroups[k.group]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
snap := value.(*atomicBucket).snapshot()
|
||||
if snap.requestCount == 0 {
|
||||
return true
|
||||
@@ -193,6 +199,17 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
return SummaryAllResult{Models: models}, nil
|
||||
}
|
||||
|
||||
func allowedGroupSet(groups []string) map[string]struct{} {
|
||||
if groups == nil {
|
||||
return nil
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
allowed[group] = struct{}{}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func bucketStart(ts int64) int64 {
|
||||
bucketSeconds := perf_metrics_setting.GetBucketSeconds()
|
||||
if bucketSeconds <= 0 {
|
||||
|
||||
@@ -229,7 +229,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (
|
||||
time.Sleep(time.Duration(5) * time.Second)
|
||||
|
||||
for {
|
||||
logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds))
|
||||
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
|
||||
step++
|
||||
rsp, err, body := updateTask(info, taskID)
|
||||
responseBody = body
|
||||
@@ -320,11 +320,10 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
|
||||
}
|
||||
}
|
||||
|
||||
//logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody))
|
||||
if a.IsSyncImageModel {
|
||||
logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody))
|
||||
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
|
||||
} else {
|
||||
logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody))
|
||||
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
|
||||
}
|
||||
|
||||
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
|
||||
|
||||
@@ -292,9 +292,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("fullRequestURL:", fullRequestURL)
|
||||
}
|
||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
@@ -323,9 +321,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("fullRequestURL:", fullRequestURL)
|
||||
}
|
||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
@@ -388,13 +384,9 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
defer func() {
|
||||
// 增加panic恢复处理
|
||||
if r := recover(); r != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped.")
|
||||
logger.LogDebug(c, "SSE ping goroutine panic recovered: %v", r)
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine stopped")
|
||||
}()
|
||||
|
||||
if pingInterval <= 0 {
|
||||
@@ -405,15 +397,11 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
// 确保在任何情况下都清理ticker
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping ticker stopped")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping ticker stopped")
|
||||
}()
|
||||
|
||||
var pingMutex sync.Mutex
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine started")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine started")
|
||||
|
||||
// 增加超时控制,防止goroutine长时间运行
|
||||
maxPingDuration := 120 * time.Minute // 最大ping持续时间
|
||||
@@ -425,9 +413,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
// 发送 ping 数据
|
||||
case <-ticker.C:
|
||||
if err := sendPingData(c, &pingMutex); err != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping error, stopping goroutine:", err.Error())
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping error, stopping goroutine: %s", err.Error())
|
||||
return
|
||||
}
|
||||
// 收到退出信号
|
||||
@@ -438,9 +424,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
return
|
||||
// 超时保护,防止goroutine无限运行
|
||||
case <-pingTimeout.C:
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine timeout, stopping")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine timeout, stopping")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -463,9 +447,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
|
||||
return
|
||||
}
|
||||
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping data sent")
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
@@ -507,9 +489,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
defer func() {
|
||||
if stopPinger != nil {
|
||||
stopPinger()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped by defer")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine stopped by defer")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -949,9 +949,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("responseBody: ", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "responseBody: %s", responseBody)
|
||||
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody)
|
||||
if handleErr != nil {
|
||||
return nil, handleErr
|
||||
|
||||
@@ -26,9 +26,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini native response body: %s", responseBody)
|
||||
|
||||
// 解析为 Gemini 原生响应格式
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
@@ -57,9 +55,7 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini native embedding response body: %s", responseBody)
|
||||
|
||||
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
|
||||
|
||||
@@ -1362,7 +1362,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount))
|
||||
logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount)
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
@@ -1422,9 +1422,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini response body: %s", responseBody)
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
|
||||
@@ -377,7 +377,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
// 打印类似 curl 命令格式的信息
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model))
|
||||
logger.LogDebug(c.Request.Context(), "--form 'model=\"%s\"'", request.Model)
|
||||
|
||||
// 遍历表单字段并打印输出
|
||||
for key, values := range formData.Value {
|
||||
@@ -386,7 +386,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
for _, value := range values {
|
||||
writer.WriteField(key, value)
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value))
|
||||
logger.LogDebug(c.Request.Context(), "--form '%s=\"%s\"'", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,8 +398,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
|
||||
// 使用 formData 中的第一个文件
|
||||
fileHeader := fileHeaders[0]
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
|
||||
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type")))
|
||||
logger.LogDebug(c.Request.Context(), "--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
|
||||
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
@@ -418,7 +418,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
// 关闭 multipart 编写器以设置分界线
|
||||
writer.Close()
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType()))
|
||||
logger.LogDebug(c.Request.Context(), "--header 'Content-Type: %s'", writer.FormDataContentType())
|
||||
return &requestBody, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +155,9 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
containStreamUsage = true
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
|
||||
usage.InputTokens, usage.OutputTokens))
|
||||
usage.InputTokens, usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,9 +200,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("upstream response body:", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "upstream response body: %s", responseBody)
|
||||
// Unmarshal to simpleResponse
|
||||
if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() {
|
||||
// 尝试解析为 openrouter enterprise
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -177,9 +178,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println("requestBody: ", string(jsonData))
|
||||
}
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
@@ -202,7 +201,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
usage, newAPIError := adaptor.DoResponse(c, httpResp, info)
|
||||
//log.Printf("usage: %v", usage)
|
||||
if newAPIError != nil {
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
|
||||
+22
-10
@@ -26,13 +26,20 @@ const (
|
||||
|
||||
var errSourceHeaderNotFound = errors.New("source header does not exist")
|
||||
|
||||
var paramOverrideKeyAuditPaths = map[string]struct{}{
|
||||
"model": {},
|
||||
"original_model": {},
|
||||
"upstream_model": {},
|
||||
"service_tier": {},
|
||||
"inference_geo": {},
|
||||
"speed": {},
|
||||
var paramOverrideSensitivePathPrefixes = []string{
|
||||
"model",
|
||||
"original_model",
|
||||
"upstream_model",
|
||||
"service_tier",
|
||||
"inference_geo",
|
||||
"speed",
|
||||
"messages",
|
||||
"input",
|
||||
"instructions",
|
||||
"system",
|
||||
"contents",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
}
|
||||
|
||||
type paramOverrideAuditRecorder struct {
|
||||
@@ -206,6 +213,7 @@ func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
|
||||
if operations, ok := tryParseOperations(paramOverride); ok {
|
||||
for _, operation := range operations {
|
||||
if shouldAuditParamPath(strings.TrimSpace(operation.Path)) ||
|
||||
shouldAuditParamPath(strings.TrimSpace(operation.From)) ||
|
||||
shouldAuditParamPath(strings.TrimSpace(operation.To)) {
|
||||
return true
|
||||
}
|
||||
@@ -255,15 +263,19 @@ func shouldAuditParamPath(path string) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
}
|
||||
_, ok := paramOverrideKeyAuditPaths[path]
|
||||
return ok
|
||||
for _, prefix := range paramOverrideSensitivePathPrefixes {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldAuditOperation(mode, path, from, to string) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range []string{path, to} {
|
||||
for _, candidate := range []string{path, from, to} {
|
||||
if shouldAuditParamPath(candidate) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
||||
@@ -2184,6 +2185,95 @@ func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisable
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideWithRelayInfoRecordsConversationBodyOperationsWhenDebugDisabled(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
|
||||
info := &RelayInfo{
|
||||
ChannelMeta: &ChannelMeta{
|
||||
ParamOverride: map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"mode": "replace",
|
||||
"path": "messages.0.content",
|
||||
"from": "hello",
|
||||
"to": "hi",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "input.0.content.0.text",
|
||||
"value": "rewritten response input",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "instructions",
|
||||
"value": "new instruction",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "append",
|
||||
"path": "contents.0.parts",
|
||||
"value": map[string]interface{}{"text": "new gemini part"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "copy",
|
||||
"from": "system",
|
||||
"to": "metadata.system_copy",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "temperature",
|
||||
"value": 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverrideWithRelayInfo([]byte(`{
|
||||
"messages":[{"role":"user","content":"hello world"}],
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"original response input"}]}],
|
||||
"instructions":"old instruction",
|
||||
"system":"old system",
|
||||
"contents":[{"role":"user","parts":[{"text":"hello gemini"}]}],
|
||||
"temperature":0.7
|
||||
}`), info)
|
||||
require.NoError(t, err)
|
||||
assertJSONEqual(t, `{
|
||||
"messages":[{"role":"user","content":"hi world"}],
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"rewritten response input"}]}],
|
||||
"instructions":"new instruction",
|
||||
"system":"old system",
|
||||
"contents":[{"role":"user","parts":[{"text":"hello gemini"},{"text":"new gemini part"}]}],
|
||||
"temperature":0.1,
|
||||
"metadata":{"system_copy":"old system"}
|
||||
}`, string(out))
|
||||
|
||||
require.Equal(t, []string{
|
||||
"replace messages.0.content from hello to hi",
|
||||
"set input.0.content.0.text = rewritten response input",
|
||||
"set instructions = new instruction",
|
||||
"append contents.0.parts with {\"text\":\"new gemini part\"}",
|
||||
"copy system -> metadata.system_copy",
|
||||
}, info.ParamOverrideAudit)
|
||||
}
|
||||
|
||||
func TestShouldAuditParamPathUsesFieldBoundaryPrefixMatching(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
|
||||
require.True(t, shouldAuditParamPath("messages"))
|
||||
require.True(t, shouldAuditParamPath("messages.0.content"))
|
||||
require.True(t, shouldAuditParamPath("systemInstruction.parts.0.text"))
|
||||
require.False(t, shouldAuditParamPath("model_name"))
|
||||
require.False(t, shouldAuditParamPath("message"))
|
||||
}
|
||||
|
||||
func assertJSONEqual(t *testing.T, want, got string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relay/channel/xinference"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -21,9 +22,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if common.DebugEnabled {
|
||||
println("reranker response body: ", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "reranker response body: %s", responseBody)
|
||||
var jinaResp dto.RerankResponse
|
||||
if info.ChannelType == constant.ChannelTypeXinference {
|
||||
var xinRerankResponse xinference.XinRerankResponse
|
||||
|
||||
@@ -102,7 +102,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
if debugBytes, bErr := storage.Bytes(); bErr == nil {
|
||||
println("requestBody: ", string(debugBytes))
|
||||
logger.LogDebug(c, "requestBody: %s", debugBytes)
|
||||
}
|
||||
}
|
||||
requestBody = common.ReaderOnly(storage)
|
||||
@@ -174,7 +174,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData)))
|
||||
logger.LogDebug(c, "text request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData)))
|
||||
logger.LogDebug(c, "converted embedding request body: %s", jsonData)
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -163,7 +163,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Gemini request body: "+string(jsonData))
|
||||
logger.LogDebug(c, "Gemini request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
||||
return newAPIErrorFromParamOverride(err)
|
||||
}
|
||||
}
|
||||
logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData))
|
||||
logger.LogDebug(c, "Gemini embedding request body: %s", jsonData)
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -45,7 +45,7 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.
|
||||
// check auto group
|
||||
autoGroup, exists := ctx.Get("auto_group")
|
||||
if exists {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup))
|
||||
logger.LogDebug(ctx, "final group: %s", autoGroup)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting()))
|
||||
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
|
||||
}
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
@@ -299,9 +299,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
|
||||
QuotaToPreConsume: preConsumedQuota,
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier))
|
||||
}
|
||||
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
|
||||
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
|
||||
@@ -72,14 +72,11 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
pingTicker = time.NewTicker(pingInterval)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
// print timeout and ping interval for debugging
|
||||
println("relay timeout seconds:", common.RelayTimeout)
|
||||
println("relay max idle conns:", common.RelayMaxIdleConns)
|
||||
println("relay max idle conns per host:", common.RelayMaxIdleConnsPerHost)
|
||||
println("streaming timeout seconds:", int64(streamingTimeout.Seconds()))
|
||||
println("ping interval seconds:", int64(pingInterval.Seconds()))
|
||||
}
|
||||
logger.LogDebug(c, "relay timeout seconds: %d", common.RelayTimeout)
|
||||
logger.LogDebug(c, "relay max idle conns: %d", common.RelayMaxIdleConns)
|
||||
logger.LogDebug(c, "relay max idle conns per host: %d", common.RelayMaxIdleConnsPerHost)
|
||||
logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds()))
|
||||
logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds()))
|
||||
|
||||
// 改进资源清理,确保所有 goroutine 正确退出
|
||||
defer func() {
|
||||
@@ -127,9 +124,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r))
|
||||
common.SafeSendBool(stopChan, true)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping goroutine exited")
|
||||
}
|
||||
logger.LogDebug(c, "ping goroutine exited")
|
||||
}()
|
||||
|
||||
// 添加超时保护,防止 goroutine 无限运行
|
||||
@@ -155,9 +150,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err)
|
||||
return
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping data sent")
|
||||
}
|
||||
logger.LogDebug(c, "ping data sent")
|
||||
case <-time.After(10 * time.Second):
|
||||
logger.LogError(c, "ping data send timeout")
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout"))
|
||||
@@ -217,9 +210,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r))
|
||||
}
|
||||
common.SafeSendBool(stopChan, true)
|
||||
if common.DebugEnabled {
|
||||
println("scanner goroutine exited")
|
||||
}
|
||||
logger.LogDebug(c, "scanner goroutine exited")
|
||||
}()
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -237,9 +228,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
|
||||
ticker.Reset(streamingTimeout)
|
||||
data := scanner.Text()
|
||||
if common.DebugEnabled {
|
||||
println(data)
|
||||
}
|
||||
logger.LogDebug(c, "stream scanner data: %s", data)
|
||||
|
||||
if len(data) < 6 {
|
||||
continue
|
||||
@@ -265,9 +254,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
}
|
||||
} else {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
if common.DebugEnabled {
|
||||
println("received [DONE], stopping scanner")
|
||||
}
|
||||
logger.LogDebug(c, "received [DONE], stopping scanner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData)))
|
||||
}
|
||||
logger.LogDebug(c, "image request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
@@ -473,7 +474,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
|
||||
c.Set("base_url", channel.GetBaseURL())
|
||||
c.Set("channel_id", originTask.ChannelId)
|
||||
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
|
||||
log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
|
||||
logger.LogDebug(c, "Midjourney action uses origin channel: id=%s, base_url=%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
|
||||
}
|
||||
midjRequest.Prompt = originTask.Prompt
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -67,9 +68,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("Rerank request body: %s", string(jsonData)))
|
||||
}
|
||||
logger.LogDebug(c, "Rerank request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
appconstant "github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
@@ -102,9 +103,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println("requestBody: ", string(jsonData))
|
||||
}
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
|
||||
perfMetricsRoute := apiRouter.Group("/perf-metrics")
|
||||
perfMetricsRoute.Use(middleware.TryUserAuth())
|
||||
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
|
||||
{
|
||||
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
|
||||
perfMetricsRoute.GET("", controller.GetPerfMetrics)
|
||||
}
|
||||
apiRouter.GET("/rankings", controller.GetRankings)
|
||||
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("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)
|
||||
|
||||
@@ -302,6 +302,11 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.GetString(src.Key))
|
||||
case "request_header":
|
||||
if c == nil || c.Request == nil || src.Key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Request.Header.Get(src.Key))
|
||||
case "gjson":
|
||||
if src.Path == "" {
|
||||
return ""
|
||||
|
||||
@@ -176,6 +176,66 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChannelAffinityValue_RequestHeader(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
ctx.Request.Header.Set("X-Affinity-Key", " tenant-123 ")
|
||||
|
||||
value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{
|
||||
Type: "request_header",
|
||||
Key: "X-Affinity-Key",
|
||||
})
|
||||
|
||||
require.Equal(t, "tenant-123", value)
|
||||
}
|
||||
|
||||
func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rule := operation_setting.ChannelAffinityRule{
|
||||
Name: "header-affinity",
|
||||
ModelRegex: []string{"^gpt-.*$"},
|
||||
PathRegex: []string{"/v1/responses"},
|
||||
KeySources: []operation_setting.ChannelAffinityKeySource{
|
||||
{Type: "request_header", Key: "X-Affinity-Key"},
|
||||
},
|
||||
IncludeRuleName: true,
|
||||
IncludeModelName: true,
|
||||
}
|
||||
|
||||
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
|
||||
t.Cleanup(func() {
|
||||
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
|
||||
})
|
||||
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
originalRules := setting.Rules
|
||||
setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...)
|
||||
t.Cleanup(func() {
|
||||
setting.Rules = originalRules
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
ctx.Request.Header.Set("X-Affinity-Key", affinityValue)
|
||||
|
||||
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
|
||||
require.True(t, found)
|
||||
require.Equal(t, 9528, channelID)
|
||||
|
||||
meta, ok := getChannelAffinityMeta(ctx)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "request_header", meta.KeySourceType)
|
||||
require.Equal(t, "X-Affinity-Key", meta.KeySourceKey)
|
||||
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
|
||||
}
|
||||
|
||||
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
|
||||
var readData []byte
|
||||
limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024}
|
||||
for _, limit := range limits {
|
||||
logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit))
|
||||
logger.LogDebug(c, "Trying to read %d bytes to determine file type", limit)
|
||||
if len(readData) < limit {
|
||||
need := limit - len(readData)
|
||||
tmp := make([]byte, need)
|
||||
|
||||
@@ -50,7 +50,7 @@ func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
|
||||
logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier())
|
||||
}
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
@@ -208,7 +208,7 @@ func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFil
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
|
||||
logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
@@ -235,9 +235,8 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
respStr := string(responseBody)
|
||||
log.Printf("respStr: %s", respStr)
|
||||
if respStr == "" {
|
||||
logger.LogDebug(c, "midjourney response body: %s", responseBody)
|
||||
if len(responseBody) == 0 {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
|
||||
} else {
|
||||
err = json.Unmarshal(responseBody, &midjResponse)
|
||||
@@ -248,7 +247,6 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
|
||||
}
|
||||
}
|
||||
}
|
||||
//log.Printf("midjResponse: %v", midjResponse)
|
||||
//for k, v := range resp.Header {
|
||||
// c.Writer.Header().Set(k, v[0])
|
||||
//}
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -112,7 +111,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
|
||||
if exists {
|
||||
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
|
||||
log.Printf("final group ratio: %f", groupRatio)
|
||||
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody)))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody)
|
||||
|
||||
snap := task.Snapshot()
|
||||
|
||||
@@ -380,7 +380,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems)
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
@@ -394,7 +394,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult)
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
@@ -488,7 +488,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
}
|
||||
} else {
|
||||
// No changes, skip update
|
||||
logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID))
|
||||
logger.LogDebug(ctx, "No update needed for task %s", task.TaskID)
|
||||
}
|
||||
|
||||
if shouldSettle {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
constant2 "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
@@ -111,7 +111,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
|
||||
width := config.Width
|
||||
height := config.Height
|
||||
log.Printf("format: %s, width: %d, height: %d", format, width, height)
|
||||
logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, width, height)
|
||||
|
||||
if isPatchBased {
|
||||
// 32x32 patch-based calculation with 1536 cap and model multiplier
|
||||
@@ -171,9 +171,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
tilesH := (finalH + 512 - 1) / 512
|
||||
tiles := tilesW * tilesH
|
||||
|
||||
if common.DebugEnabled {
|
||||
log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles)
|
||||
}
|
||||
logger.LogDebug(c, "image token scaled size: width=%d, height=%d, tiles=%d", finalW, finalH, tiles)
|
||||
|
||||
return tiles*tileTokens + baseTokens, nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ var (
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true,
|
||||
"violet": true, "grey": true, "slate": true,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package operation_setting
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type ChannelAffinityKeySource struct {
|
||||
Type string `json:"type"` // context_int, context_string, gjson
|
||||
Type string `json:"type"` // context_int, context_string, request_header, gjson
|
||||
Key string `json:"key,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const KEY_RULES = 'channel_affinity_setting.rules';
|
||||
const KEY_SOURCE_TYPES = [
|
||||
{ label: 'context_int', value: 'context_int' },
|
||||
{ label: 'context_string', value: 'context_string' },
|
||||
{ label: 'request_header', value: 'request_header' },
|
||||
{ label: 'gjson', value: 'gjson' },
|
||||
];
|
||||
|
||||
@@ -659,7 +660,11 @@ export default function SettingsChannelAffinity(props) {
|
||||
const xs = (keySources || []).map(normalizeKeySource).filter((x) => x.type);
|
||||
if (xs.length === 0) return { ok: false, message: 'Key 来源不能为空' };
|
||||
for (const x of xs) {
|
||||
if (x.type === 'context_int' || x.type === 'context_string') {
|
||||
if (
|
||||
x.type === 'context_int' ||
|
||||
x.type === 'context_string' ||
|
||||
x.type === 'request_header'
|
||||
) {
|
||||
if (!x.key) return { ok: false, message: 'Key 不能为空' };
|
||||
} else if (x.type === 'gjson') {
|
||||
if (!x.path) return { ok: false, message: 'Path 不能为空' };
|
||||
@@ -1316,7 +1321,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
</Space>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'context_int/context_string 从请求上下文读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
|
||||
'context_int/context_string 从请求上下文读取;request_header 从用户请求头读取;gjson 从入口请求的 JSON body 按 gjson path 读取。',
|
||||
)}
|
||||
</Text>
|
||||
<div style={{ marginTop: 8, marginBottom: 8 }}>
|
||||
@@ -1358,7 +1363,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={
|
||||
isGjson ? 'metadata.conversation_id' : 'user_id'
|
||||
isGjson ? 'metadata.conversation_id' : 'X-Affinity-Key'
|
||||
}
|
||||
aria-label={t('Key 或 Path')}
|
||||
value={isGjson ? src.path : src.key}
|
||||
|
||||
Vendored
+2
@@ -14,6 +14,8 @@
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#fff" />
|
||||
<!--umami-->
|
||||
<!--Google Analytics-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -107,7 +107,10 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[200px] p-0' align='start'>
|
||||
<PopoverContent
|
||||
className='min-w-[200px] max-w-[360px] p-0'
|
||||
align='start'
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
@@ -159,7 +162,10 @@ export function DataTableFacetedFilter<TData, TValue>({
|
||||
) : option.icon ? (
|
||||
<option.icon className='text-muted-foreground size-4' />
|
||||
) : null}
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate'
|
||||
title={t(option.label)}
|
||||
>
|
||||
{t(option.label)}
|
||||
</span>
|
||||
{typeof option.count === 'number' ? (
|
||||
|
||||
+10
-11
@@ -17,6 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
INTERFACE_LANGUAGE_OPTIONS,
|
||||
normalizeInterfaceLanguage,
|
||||
} from '@/i18n/languages'
|
||||
import { Languages, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
@@ -30,18 +34,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' },
|
||||
]
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const currentLanguage = normalizeInterfaceLanguage(i18n.language)
|
||||
|
||||
const handleChangeLanguage = useCallback(
|
||||
async (code: string) => {
|
||||
@@ -66,7 +62,7 @@ export function LanguageSwitcher() {
|
||||
<span className='sr-only'>{t('Change language')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{languages.map((lang) => (
|
||||
{INTERFACE_LANGUAGE_OPTIONS.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => handleChangeLanguage(lang.code)}
|
||||
@@ -74,7 +70,10 @@ export function LanguageSwitcher() {
|
||||
{lang.label}
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', i18n.language !== lang.code && 'hidden')}
|
||||
className={cn(
|
||||
'ms-auto',
|
||||
currentLanguage !== lang.code && 'hidden'
|
||||
)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -79,7 +79,9 @@ function ChatMenuItem({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
|
||||
{preset.name}
|
||||
</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
@@ -95,11 +97,13 @@ function ChatMenuItem({
|
||||
isActive={false}
|
||||
className='justify-between'
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
<span className='min-w-0 flex-1 truncate whitespace-nowrap'>
|
||||
{preset.name}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<Loader2 className='h-4 w-4 shrink-0 animate-spin' />
|
||||
) : (
|
||||
<ExternalLink className='h-4 w-4' />
|
||||
<ExternalLink className='h-4 w-4 shrink-0' />
|
||||
)}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
+157
-15
@@ -16,8 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -25,6 +25,14 @@ import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationButton } from '@/components/notification-button'
|
||||
@@ -35,6 +43,13 @@ import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import type { TopNavLink } from '../types'
|
||||
import { HeaderLogo } from './header-logo'
|
||||
|
||||
const AUTH_PROMPT_SECONDS = 5
|
||||
|
||||
type AuthPromptTarget = {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface PublicHeaderProps {
|
||||
navLinks?: TopNavLink[]
|
||||
mobileLinks?: TopNavLink[]
|
||||
@@ -65,8 +80,13 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
} = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [authPromptTarget, setAuthPromptTarget] =
|
||||
useState<AuthPromptTarget | null>(null)
|
||||
const [authPromptSecondsLeft, setAuthPromptSecondsLeft] =
|
||||
useState(AUTH_PROMPT_SECONDS)
|
||||
const { auth } = useAuthStore()
|
||||
const {
|
||||
systemName,
|
||||
@@ -98,6 +118,67 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
}
|
||||
}, [mobileOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authPromptTarget) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setAuthPromptSecondsLeft((seconds) => Math.max(seconds - 1, 0))
|
||||
}, 1000)
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const redirect = authPromptTarget.href
|
||||
setAuthPromptTarget(null)
|
||||
navigate({ to: '/sign-in', search: { redirect } })
|
||||
}, AUTH_PROMPT_SECONDS * 1000)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [authPromptTarget, navigate])
|
||||
|
||||
const closeAuthPrompt = useCallback(() => {
|
||||
setAuthPromptTarget(null)
|
||||
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
|
||||
}, [])
|
||||
|
||||
const navigateToSignIn = useCallback(() => {
|
||||
const redirect = authPromptTarget?.href || '/'
|
||||
setAuthPromptTarget(null)
|
||||
navigate({ to: '/sign-in', search: { redirect } })
|
||||
}, [authPromptTarget?.href, navigate])
|
||||
|
||||
const handleNavLinkClick = useCallback(
|
||||
(
|
||||
event: React.MouseEvent<HTMLAnchorElement>,
|
||||
link: TopNavLink,
|
||||
closeMobile = false
|
||||
) => {
|
||||
if (link.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (link.requiresAuth) {
|
||||
event.preventDefault()
|
||||
if (closeMobile) {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
|
||||
setAuthPromptTarget({
|
||||
title: t(link.title),
|
||||
href: link.href,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (closeMobile) {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
|
||||
@@ -150,7 +231,13 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200'
|
||||
aria-disabled={link.disabled}
|
||||
tabIndex={link.disabled ? -1 : undefined}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
</a>
|
||||
@@ -160,11 +247,14 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
<Link
|
||||
key={i}
|
||||
to={link.href}
|
||||
disabled={link.disabled}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
@@ -260,21 +350,42 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
<nav className='flex flex-col gap-1'>
|
||||
{links.map((link, i) => {
|
||||
const isActive = pathname === link.href
|
||||
const linkClassName = cn(
|
||||
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
mobileOpen
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-4 opacity-0',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)
|
||||
const transitionStyle = {
|
||||
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
|
||||
}
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-disabled={link.disabled}
|
||||
tabIndex={link.disabled ? -1 : undefined}
|
||||
onClick={(event) => handleNavLinkClick(event, link, true)}
|
||||
className={linkClassName}
|
||||
style={transitionStyle}
|
||||
>
|
||||
{t(link.title)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={link.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
mobileOpen
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'translate-y-4 opacity-0',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
style={{
|
||||
transitionDelay: mobileOpen ? `${100 + i * 50}ms` : '0ms',
|
||||
}}
|
||||
disabled={link.disabled}
|
||||
onClick={(event) => handleNavLinkClick(event, link, true)}
|
||||
className={linkClassName}
|
||||
style={transitionStyle}
|
||||
>
|
||||
{t(link.title)}
|
||||
</Link>
|
||||
@@ -304,6 +415,37 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!authPromptTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeAuthPrompt()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Sign in required')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Please sign in to view {{module}}.', {
|
||||
module: authPromptTarget?.title || '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
|
||||
{t('Redirecting to sign in in {{seconds}} seconds.', {
|
||||
seconds: authPromptSecondsLeft,
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={closeAuthPrompt}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Notification Dialog */}
|
||||
{showNotifications && (
|
||||
<NotificationDialog
|
||||
|
||||
+1
@@ -97,5 +97,6 @@ export type TopNavLink = {
|
||||
href: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
requiresAuth?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
interface TruncatedTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
maxWidth?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export function TruncatedText({
|
||||
text,
|
||||
className,
|
||||
maxWidth = 'max-w-[200px]',
|
||||
side = 'top',
|
||||
}: TruncatedTextProps) {
|
||||
return (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className={cn('block truncate', maxWidth, className)} />
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className='max-w-xs break-all'>
|
||||
{text}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
+5
-1
@@ -27,6 +27,7 @@ import {
|
||||
type FieldValues,
|
||||
} from 'react-hook-form'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -153,12 +154,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const { t } = useTranslation()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
const translatedBody = typeof body === 'string' ? t(body) : body
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot='form-message'
|
||||
@@ -166,7 +170,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
{translatedBody}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ export function ForgotPasswordForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button className='mt-2' disabled={isLoading || isActive}>
|
||||
<Button type='submit' className='mt-2' disabled={isLoading || isActive}>
|
||||
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
|
||||
@@ -112,8 +112,8 @@ export function ResetPasswordConfirm({
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{newPassword
|
||||
? 'Your password has been reset successfully'
|
||||
: 'Confirm the reset request to generate a new password.'}
|
||||
? t('auth.resetPasswordConfirm.success')
|
||||
: t('auth.resetPasswordConfirm.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -178,10 +178,12 @@ export function ResetPasswordConfirm({
|
||||
}
|
||||
>
|
||||
{newPassword
|
||||
? 'Return to login'
|
||||
? t('auth.resetPasswordConfirm.backToLogin')
|
||||
: isActive
|
||||
? `Retry (${secondsLeft}s)`
|
||||
: 'Confirm reset password'}
|
||||
? t('auth.resetPasswordConfirm.retry', {
|
||||
seconds: secondsLeft,
|
||||
})
|
||||
: t('auth.resetPasswordConfirm.confirm')}
|
||||
</Button>
|
||||
|
||||
{!newPassword && (
|
||||
|
||||
@@ -313,7 +313,7 @@ export function UserAuthForm({
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export function SignIn() {
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Sign in')}
|
||||
</h2>
|
||||
{!status?.self_use_mode_enabled && (
|
||||
{!status?.self_use_mode_enabled && status?.register_enabled !== false && (
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
|
||||
@@ -155,7 +155,7 @@ export function SignUpForm({
|
||||
password: data.password,
|
||||
email: data.email || undefined,
|
||||
verification_code: verificationCode || undefined,
|
||||
aff: getAffiliateCode(),
|
||||
aff_code: getAffiliateCode(),
|
||||
turnstile: turnstileToken,
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export interface RegisterPayload {
|
||||
password: string
|
||||
email?: string
|
||||
verification_code?: string
|
||||
aff?: string
|
||||
aff_code?: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn, truncateText } from '@/lib/utils'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -556,7 +557,11 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium'>{truncateText(name, 30)}</span>
|
||||
<TruncatedText
|
||||
text={name}
|
||||
className='font-medium'
|
||||
maxWidth='max-w-[180px]'
|
||||
/>
|
||||
{isPassThrough && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
|
||||
+9
-2
@@ -232,13 +232,20 @@ export function ChannelTestDialog({
|
||||
} catch (error: unknown) {
|
||||
updateTestResult(model, {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Test failed',
|
||||
error: error instanceof Error ? error.message : t('Test failed'),
|
||||
})
|
||||
} finally {
|
||||
markModelTesting(model, false)
|
||||
}
|
||||
},
|
||||
[currentRow, endpointType, isStreamTest, markModelTesting, updateTestResult]
|
||||
[
|
||||
currentRow,
|
||||
endpointType,
|
||||
isStreamTest,
|
||||
markModelTesting,
|
||||
t,
|
||||
updateTestResult,
|
||||
]
|
||||
)
|
||||
|
||||
const handleBatchTest = useCallback(
|
||||
|
||||
+31
-15
@@ -65,6 +65,8 @@ type FetchModelsDialogProps = {
|
||||
onModelsSelected?: (models: string[]) => void
|
||||
redirectModels?: string[]
|
||||
redirectSourceModels?: string[]
|
||||
customFetcher?: () => Promise<string[]>
|
||||
existingModelsOverride?: string[]
|
||||
}
|
||||
|
||||
export function FetchModelsDialog({
|
||||
@@ -73,6 +75,8 @@ export function FetchModelsDialog({
|
||||
onModelsSelected,
|
||||
redirectModels = [],
|
||||
redirectSourceModels = [],
|
||||
customFetcher,
|
||||
existingModelsOverride,
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
@@ -85,8 +89,10 @@ export function FetchModelsDialog({
|
||||
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() => parseModelsString(currentRow?.models || ''),
|
||||
[currentRow?.models]
|
||||
() =>
|
||||
existingModelsOverride ??
|
||||
parseModelsString(currentRow?.models || ''),
|
||||
[existingModelsOverride, currentRow?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
@@ -121,26 +127,33 @@ export function FetchModelsDialog({
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
if (open && (currentRow || customFetcher)) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id])
|
||||
}, [open, currentRow?.id, customFetcher])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!currentRow) return
|
||||
if (!currentRow && !customFetcher) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const response = await fetchUpstreamModels(currentRow.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
if (customFetcher) {
|
||||
const list = await customFetcher()
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
const response = await fetchUpstreamModels(currentRow!.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
@@ -153,8 +166,6 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentRow) return
|
||||
|
||||
// If onModelsSelected callback is provided, use it (form filling mode)
|
||||
if (onModelsSelected) {
|
||||
onModelsSelected(selectedModels)
|
||||
@@ -164,6 +175,7 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
if (!currentRow) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
@@ -357,12 +369,16 @@ export function FetchModelsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Fetch Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow?.name}</strong>
|
||||
{currentRow
|
||||
? <>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow.name}</strong>
|
||||
</>
|
||||
: t('Fetch available models from upstream')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!currentRow ? (
|
||||
{!currentRow && !customFetcher ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
|
||||
+6
-4
@@ -138,7 +138,7 @@ export function MultiKeyManageDialog({
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to load key status'
|
||||
error instanceof Error ? error.message : t('Failed to load key status')
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -181,7 +181,7 @@ export function MultiKeyManageDialog({
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message || 'Operation successful')
|
||||
toast.success(response.message || t('Operation successful'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
|
||||
// Reload data - reset to page 1 for bulk actions
|
||||
@@ -193,10 +193,12 @@ export function MultiKeyManageDialog({
|
||||
loadKeyStatus(currentPage, pageSize)
|
||||
}
|
||||
} else {
|
||||
toast.error(response?.message || 'Operation failed')
|
||||
toast.error(response?.message || t('Operation failed'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Operation failed')
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Operation failed')
|
||||
)
|
||||
} finally {
|
||||
setIsPerformingAction(false)
|
||||
setConfirmAction(null)
|
||||
|
||||
+38
-57
@@ -301,7 +301,6 @@ export function ChannelMutateDrawer({
|
||||
const { setOpen } = useChannels()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false)
|
||||
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
|
||||
const [channelKey, setChannelKey] = useState<string | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
@@ -698,7 +697,7 @@ export function ChannelMutateDrawer({
|
||||
try {
|
||||
const res = await getChannelKey(channelId)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'Failed to fetch channel key')
|
||||
throw new Error(res.message || t('Failed to fetch channel key'))
|
||||
}
|
||||
|
||||
const keyValue = res.data?.key ?? ''
|
||||
@@ -733,7 +732,7 @@ export function ChannelMutateDrawer({
|
||||
try {
|
||||
const res = await refreshCodexCredential(channelId)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || 'Failed to refresh credential')
|
||||
throw new Error(res.message || t('Failed to refresh credential'))
|
||||
}
|
||||
toast.success(t('Credential refreshed'))
|
||||
queryClient.invalidateQueries({
|
||||
@@ -767,43 +766,29 @@ export function ChannelMutateDrawer({
|
||||
return
|
||||
}
|
||||
|
||||
// For editing mode, open FetchModelsDialog to let user select
|
||||
if (isEditing && currentRow) {
|
||||
setFetchModelsDialogOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// For creation mode, fetch and fill all models
|
||||
const key = form.getValues('key')
|
||||
if (!key?.trim()) {
|
||||
toast.error(t('Please enter API key first'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsFetchingModels(true)
|
||||
try {
|
||||
const response = await fetchModels({
|
||||
type,
|
||||
key,
|
||||
base_url: form.getValues('base_url') || '',
|
||||
})
|
||||
|
||||
if (response.success && response.data) {
|
||||
updateModels(response.data, true)
|
||||
toast.success(
|
||||
t('Fetched {{count}} model(s) from upstream', {
|
||||
count: response.data.length,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.error(t('No models fetched from upstream'))
|
||||
// For creation mode, validate key before opening dialog
|
||||
if (!isEditing) {
|
||||
const key = form.getValues('key')
|
||||
if (!key?.trim()) {
|
||||
toast.error(t('Please enter API key first'))
|
||||
return
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(getErrorMessage(error) || t('Failed to fetch models'))
|
||||
} finally {
|
||||
setIsFetchingModels(false)
|
||||
}
|
||||
}, [isEditing, currentRow, form, t, updateModels])
|
||||
|
||||
setFetchModelsDialogOpen(true)
|
||||
}, [isEditing, form, t])
|
||||
|
||||
const createModeFetcher = useCallback(async (): Promise<string[]> => {
|
||||
const response = await fetchModels({
|
||||
type: form.getValues('type'),
|
||||
key: form.getValues('key'),
|
||||
base_url: form.getValues('base_url') || '',
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
return response.data
|
||||
}
|
||||
throw new Error(response.message || 'No models fetched from upstream')
|
||||
}, [form])
|
||||
|
||||
// Handle adding custom models
|
||||
const handleAddCustomModels = useCallback(() => {
|
||||
@@ -2234,13 +2219,8 @@ export function ChannelMutateDrawer({
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
<Sparkles className='mr-2 h-4 w-4' />
|
||||
{t('Fetch from Upstream')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -3390,19 +3370,20 @@ export function ChannelMutateDrawer({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fetch Models Dialog (for editing mode) */}
|
||||
{isEditing && currentRow && (
|
||||
<FetchModelsDialog
|
||||
open={fetchModelsDialogOpen}
|
||||
onOpenChange={setFetchModelsDialogOpen}
|
||||
onModelsSelected={(models) => {
|
||||
// Fill selected models to form
|
||||
form.setValue('models', formatModelsArray(models))
|
||||
}}
|
||||
redirectModels={redirectModelList}
|
||||
redirectSourceModels={redirectModelKeyList}
|
||||
/>
|
||||
)}
|
||||
{/* Fetch Models Dialog */}
|
||||
<FetchModelsDialog
|
||||
open={fetchModelsDialogOpen}
|
||||
onOpenChange={setFetchModelsDialogOpen}
|
||||
onModelsSelected={(models) => {
|
||||
form.setValue('models', formatModelsArray(models))
|
||||
}}
|
||||
redirectModels={redirectModelList}
|
||||
redirectSourceModels={redirectModelKeyList}
|
||||
customFetcher={!isEditing ? createModeFetcher : undefined}
|
||||
existingModelsOverride={
|
||||
!isEditing ? parseModelsString(form.getValues('models') || '') : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<SecureVerificationDialog
|
||||
open={verificationOpen}
|
||||
|
||||
+8
-3
@@ -457,8 +457,8 @@ export function transformFormDataToUpdatePayload(
|
||||
models: formData.models,
|
||||
group: formatGroups(formData.group),
|
||||
model_mapping: formData.model_mapping || null,
|
||||
priority: formData.priority || null,
|
||||
weight: formData.weight || null,
|
||||
priority: formData.priority ?? 0,
|
||||
weight: formData.weight ?? 0,
|
||||
test_model: formData.test_model || null,
|
||||
auto_ban: formData.auto_ban ?? 1,
|
||||
status: formData.status,
|
||||
@@ -484,7 +484,12 @@ export function transformFormDataToUpdatePayload(
|
||||
}
|
||||
})
|
||||
|
||||
// Send explicit empty strings for nullable JSON/text fields so GORM updates can clear them.
|
||||
// Send explicit empty strings for nullable fields so GORM updates can clear them.
|
||||
payload.base_url = formData.base_url || ''
|
||||
payload.openai_organization = formData.openai_organization || ''
|
||||
payload.test_model = formData.test_model || ''
|
||||
payload.tag = formData.tag || ''
|
||||
payload.remark = formData.remark || ''
|
||||
payload.model_mapping = formData.model_mapping || ''
|
||||
payload.status_code_mapping = formData.status_code_mapping || ''
|
||||
payload.param_override = formData.param_override || ''
|
||||
|
||||
+57
-20
@@ -52,7 +52,10 @@ import {
|
||||
} from '@/components/page-transition'
|
||||
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
|
||||
import type { ApiKey } from '@/features/keys/types'
|
||||
import { useApiInfo } from '../../hooks/use-status-data'
|
||||
import {
|
||||
useApiInfo,
|
||||
useDashboardContentVisibility,
|
||||
} from '../../hooks/use-status-data'
|
||||
import { AnnouncementsPanel } from './announcements-panel'
|
||||
import { ApiInfoPanel } from './api-info-panel'
|
||||
import { FAQPanel } from './faq-panel'
|
||||
@@ -423,6 +426,12 @@ export function OverviewDashboard() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const { items: apiInfoItems } = useApiInfo()
|
||||
const {
|
||||
apiInfo: showApiInfoPanel,
|
||||
announcements: showAnnouncementsPanel,
|
||||
faq: showFAQPanel,
|
||||
uptimeKuma: showUptimePanel,
|
||||
} = useDashboardContentVisibility()
|
||||
const [manualSetupGuideExpanded, setManualSetupGuideExpanded] = useState<
|
||||
boolean | null
|
||||
>(() => getSavedSetupGuideExpanded())
|
||||
@@ -574,6 +583,9 @@ export function OverviewDashboard() {
|
||||
const completedStepCount = startSteps.filter((step) => step.completed).length
|
||||
const setupComplete = completedStepCount === startSteps.length
|
||||
const setupGuideExpanded = manualSetupGuideExpanded ?? !setupComplete
|
||||
const showLeftContentPanels =
|
||||
isAdmin || showApiInfoPanel || showAnnouncementsPanel || showFAQPanel
|
||||
const showContentPanels = showLeftContentPanels || showUptimePanel
|
||||
|
||||
const handleSetupGuideToggle = () => {
|
||||
const nextExpanded = !setupGuideExpanded
|
||||
@@ -715,27 +727,52 @@ export function OverviewDashboard() {
|
||||
|
||||
<SummaryCards />
|
||||
|
||||
<CardStaggerContainer className='grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
|
||||
<div className='grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
{isAdmin && (
|
||||
<CardStaggerItem className='lg:col-span-2'>
|
||||
<PerformanceHealthPanel />
|
||||
{showContentPanels && (
|
||||
<CardStaggerContainer
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
showLeftContentPanels &&
|
||||
showUptimePanel &&
|
||||
'xl:grid-cols-[minmax(0,1fr)_22rem]'
|
||||
)}
|
||||
>
|
||||
{showLeftContentPanels && (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 grid-cols-1 gap-4',
|
||||
(showApiInfoPanel || showAnnouncementsPanel || showFAQPanel) &&
|
||||
'lg:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
{isAdmin && (
|
||||
<CardStaggerItem className='lg:col-span-2'>
|
||||
<PerformanceHealthPanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
{showApiInfoPanel && (
|
||||
<CardStaggerItem>
|
||||
<ApiInfoPanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
{showAnnouncementsPanel && (
|
||||
<CardStaggerItem>
|
||||
<AnnouncementsPanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
{showFAQPanel && (
|
||||
<CardStaggerItem>
|
||||
<FAQPanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showUptimePanel && (
|
||||
<CardStaggerItem>
|
||||
<UptimePanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
<CardStaggerItem>
|
||||
<ApiInfoPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<AnnouncementsPanel />
|
||||
</CardStaggerItem>
|
||||
<CardStaggerItem>
|
||||
<FAQPanel />
|
||||
</CardStaggerItem>
|
||||
</div>
|
||||
<CardStaggerItem>
|
||||
<UptimePanel />
|
||||
</CardStaggerItem>
|
||||
</CardStaggerContainer>
|
||||
</CardStaggerContainer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-1
@@ -27,7 +27,7 @@ export function useStatusData<T = unknown>(
|
||||
dataKey: string
|
||||
): { items: T[]; loading: boolean } {
|
||||
const { status, loading } = useStatus()
|
||||
const enabled = status?.[enabledKey] ?? false
|
||||
const enabled = status ? status[enabledKey] !== false : false
|
||||
const items = (enabled ? status?.[dataKey] || [] : []) as T[]
|
||||
|
||||
return { items, loading }
|
||||
@@ -56,3 +56,18 @@ export function useAnnouncements() {
|
||||
export function useFAQ() {
|
||||
return useStatusData<FAQItem>('faq_enabled', 'faq')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard content panel visibility
|
||||
*/
|
||||
export function useDashboardContentVisibility() {
|
||||
const { status } = useStatus()
|
||||
const hasStatus = Boolean(status)
|
||||
|
||||
return {
|
||||
apiInfo: hasStatus && status?.api_info_enabled !== false,
|
||||
announcements: hasStatus && status?.announcements_enabled !== false,
|
||||
faq: hasStatus && status?.faq_enabled !== false,
|
||||
uptimeKuma: hasStatus && status?.uptime_kuma_enabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -236,7 +236,7 @@ export function Dashboard() {
|
||||
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
|
||||
{showSectionTabs ? (
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
|
||||
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -65,7 +65,7 @@ import { MultiSelect } from '@/components/multi-select'
|
||||
import { createApiKey, updateApiKey, getApiKey } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
apiKeyFormSchema,
|
||||
getApiKeyFormSchema,
|
||||
type ApiKeyFormValues,
|
||||
getApiKeyFormDefaultValues,
|
||||
transformFormDataToPayload,
|
||||
@@ -152,9 +152,10 @@ export function ApiKeysMutateDrawer({
|
||||
})
|
||||
)
|
||||
const backendHasAuto = groups.some((g) => g.value === 'auto')
|
||||
const schema = getApiKeyFormSchema(t)
|
||||
|
||||
const form = useForm<ApiKeyFormValues>({
|
||||
resolver: zodResolver(apiKeyFormSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: getApiKeyFormDefaultValues(defaultUseAutoGroup),
|
||||
})
|
||||
|
||||
@@ -239,6 +240,10 @@ export function ApiKeysMutateDrawer({
|
||||
}
|
||||
}
|
||||
|
||||
const onInvalid: SubmitErrorHandler<ApiKeyFormValues> = () => {
|
||||
toast.error(t('Please fix the highlighted fields before saving'))
|
||||
}
|
||||
|
||||
const handleSetExpiry = (months: number, days: number, hours: number) => {
|
||||
if (months === 0 && days === 0 && hours === 0) {
|
||||
form.setValue('expired_time', undefined)
|
||||
@@ -291,7 +296,7 @@ export function ApiKeysMutateDrawer({
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='api-key-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
||||
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
|
||||
>
|
||||
<ApiKeyFormSection
|
||||
@@ -605,8 +610,8 @@ export function ApiKeysMutateDrawer({
|
||||
{t('Close')}
|
||||
</SheetClose>
|
||||
<Button
|
||||
form='api-key-form'
|
||||
type='submit'
|
||||
type='button'
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
|
||||
@@ -318,6 +318,7 @@ export function ApiKeysTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: API_KEY_STATUS_OPTIONS,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
+37
-13
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
|
||||
import { DEFAULT_GROUP } from '../constants'
|
||||
import { type ApiKeyFormData, type ApiKey } from '../types'
|
||||
@@ -25,19 +26,40 @@ import { type ApiKeyFormData, type ApiKey } from '../types'
|
||||
// Form Schema
|
||||
// ============================================================================
|
||||
|
||||
export const apiKeyFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
remain_quota_dollars: z.number().min(0).optional(),
|
||||
expired_time: z.date().optional(),
|
||||
unlimited_quota: z.boolean(),
|
||||
model_limits: z.array(z.string()),
|
||||
allow_ips: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
cross_group_retry: z.boolean().optional(),
|
||||
tokenCount: z.number().min(1).optional(),
|
||||
})
|
||||
export function getApiKeyFormSchema(t: TFunction) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().min(1, t('Please enter a name')),
|
||||
remain_quota_dollars: z.number().optional(),
|
||||
expired_time: z.date().optional(),
|
||||
unlimited_quota: z.boolean(),
|
||||
model_limits: z.array(z.string()),
|
||||
allow_ips: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
cross_group_retry: z.boolean().optional(),
|
||||
tokenCount: z.number().min(1).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.unlimited_quota) {
|
||||
return
|
||||
}
|
||||
|
||||
export type ApiKeyFormValues = z.infer<typeof apiKeyFormSchema>
|
||||
if (
|
||||
data.remain_quota_dollars === undefined ||
|
||||
data.remain_quota_dollars < 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['remain_quota_dollars'],
|
||||
message: t('Quota must be zero or greater'),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ApiKeyFormValues = z.infer<
|
||||
ReturnType<typeof getApiKeyFormSchema>
|
||||
>
|
||||
|
||||
// ============================================================================
|
||||
// Form Defaults
|
||||
@@ -100,7 +122,9 @@ export function transformApiKeyToFormDefaults(
|
||||
): ApiKeyFormValues {
|
||||
return {
|
||||
name: apiKey.name,
|
||||
remain_quota_dollars: quotaUnitsToDollars(apiKey.remain_quota),
|
||||
remain_quota_dollars: apiKey.unlimited_quota
|
||||
? 0
|
||||
: quotaUnitsToDollars(apiKey.remain_quota),
|
||||
expired_time:
|
||||
apiKey.expired_time > 0
|
||||
? new Date(apiKey.expired_time * 1000)
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
// Form Utilities
|
||||
// ============================================================================
|
||||
export {
|
||||
apiKeyFormSchema,
|
||||
getApiKeyFormSchema,
|
||||
type ApiKeyFormValues,
|
||||
API_KEY_FORM_DEFAULT_VALUES,
|
||||
getApiKeyFormDefaultValues,
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ function ModelsContent() {
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
|
||||
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
|
||||
{MODELS_SECTION_IDS.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
className='gap-1.5'
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
{t('Previous')}
|
||||
{t('Previous page')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -115,7 +115,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
disabled={currentPage >= totalPages}
|
||||
className='gap-1.5'
|
||||
>
|
||||
{t('Next')}
|
||||
{t('Next page')}
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -920,17 +920,17 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
<ModelHeader model={props.model} />
|
||||
|
||||
<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'>
|
||||
<TabsList className='bg-muted/60 grid w-full grid-cols-3 gap-1 rounded-lg p-1 group-data-horizontal/tabs:h-auto'>
|
||||
{TAB_VALUES.map((value) => {
|
||||
const Icon = TAB_META[value].icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className='h-8 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
className='h-8 min-w-0 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
<span>{t(TAB_META[value].labelKey)}</span>
|
||||
<span className='truncate'>{t(TAB_META[value].labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
|
||||
+10
-24
@@ -17,6 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
INTERFACE_LANGUAGE_OPTIONS,
|
||||
normalizeInterfaceLanguage,
|
||||
} from '@/i18n/languages'
|
||||
import { Languages, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -34,24 +38,6 @@ import { updateUserLanguage } from '../api'
|
||||
import { parseUserSettings } from '../lib'
|
||||
import type { UserProfile } from '../types'
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'vi', label: 'Tiếng Việt' },
|
||||
] as const
|
||||
|
||||
function normalizeLanguage(value?: string | null): string {
|
||||
if (!value) return 'en'
|
||||
const normalized = value.trim().replace(/_/g, '-').toLowerCase()
|
||||
if (normalized.startsWith('zh')) return 'zh'
|
||||
return LANGUAGE_OPTIONS.some((lang) => lang.value === normalized)
|
||||
? normalized
|
||||
: 'en'
|
||||
}
|
||||
|
||||
type LanguagePreferencesCardProps = {
|
||||
profile: UserProfile | null
|
||||
onProfileUpdate: () => void
|
||||
@@ -64,7 +50,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
|
||||
const savedLanguage = useMemo(() => {
|
||||
const settings = parseUserSettings(props.profile?.setting)
|
||||
return normalizeLanguage(settings.language || i18n.language)
|
||||
return normalizeInterfaceLanguage(settings.language || i18n.language)
|
||||
}, [props.profile?.setting, i18n.language])
|
||||
|
||||
const [currentLanguage, setCurrentLanguage] = useState(savedLanguage)
|
||||
@@ -75,7 +61,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
|
||||
const handleLanguageChange = async (language: string | null) => {
|
||||
if (!language) return
|
||||
const nextLanguage = normalizeLanguage(language)
|
||||
const nextLanguage = normalizeInterfaceLanguage(language)
|
||||
if (nextLanguage === currentLanguage) return
|
||||
|
||||
const previousLanguage = currentLanguage
|
||||
@@ -132,8 +118,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
<div className='flex items-center gap-2 sm:min-w-48'>
|
||||
<Select
|
||||
items={[
|
||||
...LANGUAGE_OPTIONS.map((language) => ({
|
||||
value: language.value,
|
||||
...INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
|
||||
value: language.code,
|
||||
label: language.label,
|
||||
})),
|
||||
]}
|
||||
@@ -146,8 +132,8 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{INTERFACE_LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ProfileSettingsCard({
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
|
||||
<TabsList className='grid group-data-horizontal/tabs:h-10 w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1'>
|
||||
<TabsTrigger
|
||||
value='bindings'
|
||||
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
|
||||
|
||||
@@ -173,6 +173,7 @@ export function RedemptionsTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: redemptionStatusOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Plus, Edit, Trash2, Save } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getBgColorClass } from '@/lib/colors'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -98,20 +99,20 @@ const createApiInfoSchema = (t: (key: string) => string) =>
|
||||
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'blue', label: 'Blue', bgClass: 'bg-blue-500' },
|
||||
{ value: 'green', label: 'Green', bgClass: 'bg-green-500' },
|
||||
{ value: 'cyan', label: 'Cyan', bgClass: 'bg-cyan-500' },
|
||||
{ value: 'purple', label: 'Purple', bgClass: 'bg-purple-500' },
|
||||
{ value: 'pink', label: 'Pink', bgClass: 'bg-pink-500' },
|
||||
{ value: 'red', label: 'Red', bgClass: 'bg-red-500' },
|
||||
{ value: 'orange', label: 'Orange', bgClass: 'bg-orange-500' },
|
||||
{ value: 'amber', label: 'Amber', bgClass: 'bg-amber-500' },
|
||||
{ value: 'yellow', label: 'Yellow', bgClass: 'bg-yellow-500' },
|
||||
{ value: 'lime', label: 'Lime', bgClass: 'bg-lime-500' },
|
||||
{ value: 'teal', label: 'Teal', bgClass: 'bg-teal-500' },
|
||||
{ value: 'indigo', label: 'Indigo', bgClass: 'bg-indigo-500' },
|
||||
{ value: 'violet', label: 'Violet', bgClass: 'bg-violet-500' },
|
||||
{ value: 'slate', label: 'Slate', bgClass: 'bg-slate-500' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
{ value: 'cyan', label: 'Cyan' },
|
||||
{ value: 'purple', label: 'Purple' },
|
||||
{ value: 'pink', label: 'Pink' },
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'orange', label: 'Orange' },
|
||||
{ value: 'amber', label: 'Amber' },
|
||||
{ value: 'yellow', label: 'Yellow' },
|
||||
{ value: 'lime', label: 'Lime' },
|
||||
{ value: 'teal', label: 'Teal' },
|
||||
{ value: 'indigo', label: 'Indigo' },
|
||||
{ value: 'violet', label: 'Violet' },
|
||||
{ value: 'slate', label: 'Slate' },
|
||||
]
|
||||
|
||||
export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
@@ -249,12 +250,13 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await updateOption.mutateAsync({
|
||||
const result = await updateOption.mutateAsync({
|
||||
key: 'console_setting.api_info',
|
||||
value: JSON.stringify(apiInfoList),
|
||||
})
|
||||
setHasChanges(false)
|
||||
toast.success(t('API info saved successfully'))
|
||||
if (result.success) {
|
||||
setHasChanges(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('Failed to save API info'))
|
||||
}
|
||||
@@ -270,11 +272,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const getColorClass = (color: string) => {
|
||||
return (
|
||||
colorOptions.find((opt) => opt.value === color)?.bgClass || 'bg-blue-500'
|
||||
)
|
||||
}
|
||||
const getColorClass = (color: string) => getBgColorClass(color)
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -488,7 +486,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
label: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${option.bgClass}`}
|
||||
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
@@ -509,7 +507,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`h-4 w-4 rounded-full ${option.bgClass}`}
|
||||
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
|
||||
/>
|
||||
{option.label}
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
|
||||
items={[
|
||||
...granularityOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
label: t(option.label),
|
||||
})),
|
||||
]}
|
||||
onValueChange={field.onChange}
|
||||
@@ -167,7 +167,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) {
|
||||
<SelectGroup>
|
||||
{granularityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
+6
-1
@@ -50,7 +50,12 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { RULE_TEMPLATES } from './constants'
|
||||
import type { AffinityRule, KeySource } from './types'
|
||||
|
||||
const KEY_SOURCE_TYPES = ['context_int', 'context_string', 'gjson'] as const
|
||||
const KEY_SOURCE_TYPES = [
|
||||
'context_int',
|
||||
'context_string',
|
||||
'request_header',
|
||||
'gjson',
|
||||
] as const
|
||||
|
||||
const CONTEXT_KEY_PRESETS = [
|
||||
'id',
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export interface KeySource {
|
||||
type: 'context_int' | 'context_string' | 'gjson'
|
||||
type: 'context_int' | 'context_string' | 'request_header' | 'gjson'
|
||||
key?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
+41
-13
@@ -431,9 +431,22 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className='text-muted-foreground truncate text-sm hover:underline'>
|
||||
{sensitiveVisible ? log.username : '••••'}
|
||||
</span>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='text-muted-foreground max-w-[100px] truncate text-sm hover:underline' />
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? log.username : '••••'}
|
||||
</TooltipTrigger>
|
||||
{sensitiveVisible && log.username.length > 12 && (
|
||||
<TooltipContent side='top'>
|
||||
{log.username}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
@@ -468,15 +481,30 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
if (groupRatioText) metaParts.push(groupRatioText)
|
||||
|
||||
return (
|
||||
<div className='flex max-w-[150px] flex-col gap-0.5'>
|
||||
<StatusBadge
|
||||
label={displayName}
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
<div className='flex max-w-[200px] flex-col gap-0.5'>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className='max-w-full' />
|
||||
}
|
||||
>
|
||||
<StatusBadge
|
||||
label={displayName}
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{sensitiveVisible && tokenName.length > 16 && (
|
||||
<TooltipContent side='top' className='max-w-xs break-all'>
|
||||
{tokenName}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{metaParts.length > 0 && (
|
||||
<span className='text-muted-foreground/60 truncate text-[11px]'>
|
||||
{metaParts.join(' · ')}
|
||||
@@ -486,7 +514,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
},
|
||||
meta: { label: t('Token') },
|
||||
size: 130,
|
||||
size: 160,
|
||||
})
|
||||
|
||||
columns.push(
|
||||
|
||||
@@ -985,9 +985,8 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Param override (admin only) */}
|
||||
{props.isAdmin &&
|
||||
other?.po &&
|
||||
{/* Param override */}
|
||||
{other?.po &&
|
||||
Array.isArray(other.po) &&
|
||||
other.po.length > 0 && (
|
||||
<DetailSection
|
||||
|
||||
@@ -150,6 +150,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ function UsageLogsContent() {
|
||||
<div className='space-y-4'>
|
||||
{showTaskSwitcher && (
|
||||
<Tabs value={activeCategory} onValueChange={handleSectionChange}>
|
||||
<TabsList className='h-auto max-w-full flex-wrap justify-start'>
|
||||
<TabsList className='group-data-horizontal/tabs:h-auto max-w-full flex-wrap justify-start'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
|
||||
@@ -121,6 +121,17 @@ export function UsersMutateDrawer({
|
||||
const currentQuotaRaw = form.watch('quota_dollars') || 0
|
||||
|
||||
const onSubmit = async (data: UserFormValues) => {
|
||||
if (!isUpdate) {
|
||||
const passwordLength = data.password?.length || 0
|
||||
if (passwordLength < 8 || passwordLength > 20) {
|
||||
form.setError('password', {
|
||||
type: 'manual',
|
||||
message: t('Password must be between 8 and 20 characters'),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload = transformFormDataToPayload(data, currentRow?.id)
|
||||
|
||||
@@ -187,11 +187,13 @@ export function UsersTable() {
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: getUserStatusOptions(t),
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'role',
|
||||
title: t('Role'),
|
||||
options: getUserRoleOptions(t),
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
||||
@@ -240,7 +240,7 @@ export function RechargeFormCard({
|
||||
className={cn(
|
||||
'hover:border-foreground flex min-h-16 flex-col items-start rounded-lg px-3 py-2.5 text-left whitespace-normal sm:min-h-[72px] sm:p-4',
|
||||
selectedPreset === preset.value
|
||||
? 'border-foreground bg-foreground/5'
|
||||
? 'border-foreground bg-foreground/5 dark:border-foreground dark:bg-foreground/10'
|
||||
: 'border-muted'
|
||||
)}
|
||||
onClick={() => onSelectPreset(preset)}
|
||||
|
||||
+1
-1
@@ -25,5 +25,5 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function generateAffiliateLink(affCode: string): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return `${window.location.origin}/register?aff=${affCode}`
|
||||
return `${window.location.origin}/sign-up?aff=${affCode}`
|
||||
}
|
||||
|
||||
+10
-69
@@ -20,77 +20,16 @@ import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
|
||||
|
||||
export type TopNavLink = {
|
||||
title: string
|
||||
href: string
|
||||
disabled?: boolean
|
||||
requiresAuth?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
// Default navigation configuration
|
||||
const DEFAULT_HEADER_NAV_MODULES = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: { enabled: true, requireAuth: false },
|
||||
rankings: { enabled: true, requireAuth: false },
|
||||
docs: true,
|
||||
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):
|
||||
@@ -110,8 +49,10 @@ export function useTopNavLinks(): TopNavLink[] {
|
||||
|
||||
// Parse HeaderNavModules
|
||||
const modules = useMemo(() => {
|
||||
return parseHeaderNavModules(status?.HeaderNavModules)
|
||||
}, [status?.HeaderNavModules])
|
||||
return parseHeaderNavModulesFromStatus(
|
||||
status as Record<string, unknown> | null
|
||||
)
|
||||
}, [status])
|
||||
|
||||
// Documentation link (may be external)
|
||||
const docsLink: string | undefined = status?.docs_link as string | undefined
|
||||
@@ -133,15 +74,15 @@ export function useTopNavLinks(): TopNavLink[] {
|
||||
// Pricing
|
||||
const pricing = modules?.pricing
|
||||
if (pricing && typeof pricing === 'object' && pricing.enabled) {
|
||||
const disabled = pricing.requireAuth && !isAuthed
|
||||
links.push({ title: t('Model Square'), href: '/pricing', disabled })
|
||||
const requiresAuth = pricing.requireAuth && !isAuthed
|
||||
links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
|
||||
}
|
||||
|
||||
// Rankings
|
||||
const rankings = modules?.rankings
|
||||
if (rankings && typeof rankings === 'object' && rankings.enabled) {
|
||||
const disabled = rankings.requireAuth && !isAuthed
|
||||
links.push({ title: t('Rankings'), href: '/rankings', disabled })
|
||||
const requiresAuth = rankings.requireAuth && !isAuthed
|
||||
links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
|
||||
}
|
||||
|
||||
// Docs (supports external links)
|
||||
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export const INTERFACE_LANGUAGE_OPTIONS = [
|
||||
{ code: 'zh', label: '简体中文' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' },
|
||||
] as const
|
||||
|
||||
export type InterfaceLanguageCode =
|
||||
(typeof INTERFACE_LANGUAGE_OPTIONS)[number]['code']
|
||||
|
||||
export function normalizeInterfaceLanguage(value?: string | null): string {
|
||||
if (!value) return 'en'
|
||||
|
||||
const normalized = value.trim().replace(/_/g, '-').toLowerCase()
|
||||
if (normalized.startsWith('zh')) return 'zh'
|
||||
|
||||
return INTERFACE_LANGUAGE_OPTIONS.some((lang) => lang.code === normalized)
|
||||
? normalized
|
||||
: 'en'
|
||||
}
|
||||
+4
-4
@@ -11,25 +11,25 @@
|
||||
"file": "fr.json",
|
||||
"missingCount": 0,
|
||||
"extrasCount": 0,
|
||||
"untranslatedCount": 1
|
||||
"untranslatedCount": 21
|
||||
},
|
||||
"ja": {
|
||||
"file": "ja.json",
|
||||
"missingCount": 0,
|
||||
"extrasCount": 0,
|
||||
"untranslatedCount": 92
|
||||
"untranslatedCount": 120
|
||||
},
|
||||
"ru": {
|
||||
"file": "ru.json",
|
||||
"missingCount": 0,
|
||||
"extrasCount": 0,
|
||||
"untranslatedCount": 107
|
||||
"untranslatedCount": 135
|
||||
},
|
||||
"vi": {
|
||||
"file": "vi.json",
|
||||
"missingCount": 0,
|
||||
"extrasCount": 0,
|
||||
"untranslatedCount": 3
|
||||
"untranslatedCount": 23
|
||||
},
|
||||
"zh": {
|
||||
"file": "zh.json",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user