Explorar el Código

Merge remote-tracking branch 'remotes/up-origin/main' into feat-channel-block-edit

JoeyLearnsToCode hace 5 meses
padre
commit
3ed0ae83f1
Se han modificado 36 ficheros con 711 adiciones y 175 borrados
  1. 4 4
      .github/workflows/linux-release.yml
  2. 2 2
      .github/workflows/macos-release.yml
  3. 2 2
      .github/workflows/windows-release.yml
  4. 32 1
      common/sys_log.go
  5. 72 0
      common/utils.go
  6. 4 2
      constant/task.go
  7. 84 2
      controller/channel.go
  8. 31 0
      dto/gemini.go
  9. 3 2
      dto/openai_request.go
  10. 7 1
      main.go
  11. 22 1
      relay/channel/aws/constants.go
  12. 6 8
      relay/channel/task/vidu/adaptor.go
  13. 13 6
      relay/channel/volcengine/adaptor.go
  14. 5 0
      relay/channel/volcengine/constants.go
  15. 3 4
      relay/channel/xunfei/relay-xunfei.go
  16. 26 0
      relay/claude_handler.go
  17. 10 26
      relay/common/relay_utils.go
  18. 27 0
      relay/gemini_handler.go
  19. 9 0
      web/jsconfig.json
  20. 2 2
      web/src/components/common/markdown/MarkdownRenderer.jsx
  21. 87 83
      web/src/components/layout/headerbar/UserArea.jsx
  22. 8 1
      web/src/components/settings/personal/cards/NotificationSettings.jsx
  23. 62 8
      web/src/components/table/channels/modals/EditChannelModal.jsx
  24. 42 1
      web/src/components/table/channels/modals/MultiKeyManageModal.jsx
  25. 7 0
      web/src/components/table/mj-logs/MjLogsFilters.jsx
  26. 18 3
      web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
  27. 7 0
      web/src/components/table/task-logs/TaskLogsFilters.jsx
  28. 7 0
      web/src/components/table/usage-logs/UsageLogsFilters.jsx
  29. 2 0
      web/src/constants/common.constant.js
  30. 49 0
      web/src/constants/console.constants.js
  31. 8 7
      web/src/helpers/api.js
  32. 11 7
      web/src/helpers/utils.jsx
  33. 23 1
      web/src/hooks/common/useSidebar.js
  34. 1 1
      web/src/hooks/dashboard/useDashboardStats.jsx
  35. 9 0
      web/src/i18n/locales/en.json
  36. 6 0
      web/vite.config.js

+ 4 - 4
.github/workflows/linux-release.yml

@@ -38,21 +38,21 @@ jobs:
       - name: Build Backend (amd64)
         run: |
           go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
+          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
 
       - name: Build Backend (arm64)
         run: |
           sudo apt-get update
           DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
-          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
+          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64
 
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
           files: |
-            one-api
-            one-api-arm64
+            new-api
+            new-api-arm64
           draft: true
           generate_release_notes: true
         env:

+ 2 - 2
.github/workflows/macos-release.yml

@@ -39,12 +39,12 @@ jobs:
       - name: Build Backend
         run: |
           go mod download
-          go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
+          go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: one-api-macos
+          files: new-api-macos
           draft: true
           generate_release_notes: true
         env:

+ 2 - 2
.github/workflows/windows-release.yml

@@ -41,12 +41,12 @@ jobs:
       - name: Build Backend
         run: |
           go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
+          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: one-api.exe
+          files: new-api.exe
           draft: true
           generate_release_notes: true
         env:

+ 32 - 1
common/sys_log.go

@@ -2,9 +2,10 @@ package common
 
 import (
 	"fmt"
-	"github.com/gin-gonic/gin"
 	"os"
 	"time"
+
+	"github.com/gin-gonic/gin"
 )
 
 func SysLog(s string) {
@@ -22,3 +23,33 @@ func FatalLog(v ...any) {
 	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
 	os.Exit(1)
 }
+
+func LogStartupSuccess(startTime time.Time, port string) {
+
+	duration := time.Since(startTime)
+	durationMs := duration.Milliseconds()
+
+	// Get network IPs
+	networkIps := GetNetworkIps()
+
+	// Print blank line for spacing
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+
+	// Print the main success message
+	fmt.Fprintf(gin.DefaultWriter, "  \033[32m%s %s\033[0m  ready in %d ms\n", SystemName, Version, durationMs)
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+
+	// Skip fancy startup message in container environments
+	if !IsRunningInContainer() {
+		// Print local URL
+		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mLocal:\033[0m   http://localhost:%s/\n", port)
+	}
+
+	// Print network URLs
+	for _, ip := range networkIps {
+		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
+	}
+
+	// Print blank line for spacing
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+}

+ 72 - 0
common/utils.go

@@ -68,6 +68,78 @@ func GetIp() (ip string) {
 	return
 }
 
+func GetNetworkIps() []string {
+	var networkIps []string
+	ips, err := net.InterfaceAddrs()
+	if err != nil {
+		log.Println(err)
+		return networkIps
+	}
+
+	for _, a := range ips {
+		if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
+			if ipNet.IP.To4() != nil {
+				ip := ipNet.IP.String()
+				// Include common private network ranges
+				if strings.HasPrefix(ip, "10.") ||
+					strings.HasPrefix(ip, "172.") ||
+					strings.HasPrefix(ip, "192.168.") {
+					networkIps = append(networkIps, ip)
+				}
+			}
+		}
+	}
+	return networkIps
+}
+
+// IsRunningInContainer detects if the application is running inside a container
+func IsRunningInContainer() bool {
+	// Method 1: Check for .dockerenv file (Docker containers)
+	if _, err := os.Stat("/.dockerenv"); err == nil {
+		return true
+	}
+
+	// Method 2: Check cgroup for container indicators
+	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
+		content := string(data)
+		if strings.Contains(content, "docker") ||
+			strings.Contains(content, "containerd") ||
+			strings.Contains(content, "kubepods") ||
+			strings.Contains(content, "/lxc/") {
+			return true
+		}
+	}
+
+	// Method 3: Check environment variables commonly set by container runtimes
+	containerEnvVars := []string{
+		"KUBERNETES_SERVICE_HOST",
+		"DOCKER_CONTAINER",
+		"container",
+	}
+
+	for _, envVar := range containerEnvVars {
+		if os.Getenv(envVar) != "" {
+			return true
+		}
+	}
+
+	// Method 4: Check if init process is not the traditional init
+	if data, err := os.ReadFile("/proc/1/comm"); err == nil {
+		comm := strings.TrimSpace(string(data))
+		// In containers, process 1 is often not "init" or "systemd"
+		if comm != "init" && comm != "systemd" {
+			// Additional check: if it's a common container entrypoint
+			if strings.Contains(comm, "docker") ||
+				strings.Contains(comm, "containerd") ||
+				strings.Contains(comm, "runc") {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
 var sizeKB = 1024
 var sizeMB = sizeKB * 1024
 var sizeGB = sizeMB * 1024

+ 4 - 2
constant/task.go

@@ -11,8 +11,10 @@ const (
 	SunoActionMusic  = "MUSIC"
 	SunoActionLyrics = "LYRICS"
 
-	TaskActionGenerate     = "generate"
-	TaskActionTextGenerate = "textGenerate"
+	TaskActionGenerate          = "generate"
+	TaskActionTextGenerate      = "textGenerate"
+	TaskActionFirstTailGenerate = "firstTailGenerate"
+	TaskActionReferenceGenerate = "referenceGenerate"
 )
 
 var SunoModel2Action = map[string]string{

+ 84 - 2
controller/channel.go

@@ -188,6 +188,8 @@ func FetchUpstreamModels(c *gin.Context) {
 		url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
 	case constant.ChannelTypeAli:
 		url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
+	case constant.ChannelTypeZhipu_v4:
+		url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
 	default:
 		url = fmt.Sprintf("%s/v1/models", baseURL)
 	}
@@ -1101,8 +1103,8 @@ func CopyChannel(c *gin.Context) {
 // MultiKeyManageRequest represents the request for multi-key management operations
 type MultiKeyManageRequest struct {
 	ChannelId int    `json:"channel_id"`
-	Action    string `json:"action"`              // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
-	KeyIndex  *int   `json:"key_index,omitempty"` // for disable_key and enable_key actions
+	Action    string `json:"action"`              // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status"
+	KeyIndex  *int   `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions
 	Page      int    `json:"page,omitempty"`      // for get_key_status pagination
 	PageSize  int    `json:"page_size,omitempty"` // for get_key_status pagination
 	Status    *int   `json:"status,omitempty"`    // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
@@ -1430,6 +1432,86 @@ func ManageMultiKeys(c *gin.Context) {
 		})
 		return
 
+	case "delete_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要删除的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		keys := channel.GetKeys()
+		var remainingKeys []string
+		var newStatusList = make(map[int]int)
+		var newDisabledTime = make(map[int]int64)
+		var newDisabledReason = make(map[int]string)
+
+		newIndex := 0
+		for i, key := range keys {
+			// 跳过要删除的密钥
+			if i == keyIndex {
+				continue
+			}
+
+			remainingKeys = append(remainingKeys, key)
+
+			// 保留其他密钥的状态信息,重新索引
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 {
+					newStatusList[newIndex] = status
+				}
+			}
+			if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+				if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
+					newDisabledTime[newIndex] = t
+				}
+			}
+			if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+				if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
+					newDisabledReason[newIndex] = r
+				}
+			}
+			newIndex++
+		}
+
+		if len(remainingKeys) == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "不能删除最后一个密钥",
+			})
+			return
+		}
+
+		// Update channel with remaining keys
+		channel.Key = strings.Join(remainingKeys, "\n")
+		channel.ChannelInfo.MultiKeySize = len(remainingKeys)
+		channel.ChannelInfo.MultiKeyStatusList = newStatusList
+		channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
+		channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已删除",
+		})
+		return
+
 	case "delete_disabled_keys":
 		keys := channel.GetKeys()
 		var remainingKeys []string

+ 31 - 0
dto/gemini.go

@@ -14,7 +14,30 @@ type GeminiChatRequest struct {
 	SafetySettings     []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
 	GenerationConfig   GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
 	Tools              json.RawMessage            `json:"tools,omitempty"`
+	ToolConfig         *ToolConfig                `json:"toolConfig,omitempty"`
 	SystemInstructions *GeminiChatContent         `json:"systemInstruction,omitempty"`
+	CachedContent      string                     `json:"cachedContent,omitempty"`
+}
+
+type ToolConfig struct {
+	FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
+	RetrievalConfig       *RetrievalConfig       `json:"retrievalConfig,omitempty"`
+}
+
+type FunctionCallingConfig struct {
+	Mode                 FunctionCallingConfigMode `json:"mode,omitempty"`
+	AllowedFunctionNames []string                  `json:"allowedFunctionNames,omitempty"`
+}
+type FunctionCallingConfigMode string
+
+type RetrievalConfig struct {
+	LatLng       *LatLng `json:"latLng,omitempty"`
+	LanguageCode string  `json:"languageCode,omitempty"`
+}
+
+type LatLng struct {
+	Latitude  *float64 `json:"latitude,omitempty"`
+	Longitude *float64 `json:"longitude,omitempty"`
 }
 
 func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -239,12 +262,20 @@ type GeminiChatGenerationConfig struct {
 	StopSequences      []string              `json:"stopSequences,omitempty"`
 	ResponseMimeType   string                `json:"responseMimeType,omitempty"`
 	ResponseSchema     any                   `json:"responseSchema,omitempty"`
+	ResponseJsonSchema json.RawMessage       `json:"responseJsonSchema,omitempty"`
+	PresencePenalty    *float32              `json:"presencePenalty,omitempty"`
+	FrequencyPenalty   *float32              `json:"frequencyPenalty,omitempty"`
+	ResponseLogprobs   bool                  `json:"responseLogprobs,omitempty"`
+	Logprobs           *int32                `json:"logprobs,omitempty"`
+	MediaResolution    MediaResolution       `json:"mediaResolution,omitempty"`
 	Seed               int64                 `json:"seed,omitempty"`
 	ResponseModalities []string              `json:"responseModalities,omitempty"`
 	ThinkingConfig     *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
 	SpeechConfig       json.RawMessage       `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
 }
 
+type MediaResolution string
+
 type GeminiChatCandidate struct {
 	Content       GeminiChatContent        `json:"content"`
 	FinishReason  *string                  `json:"finishReason"`

+ 3 - 2
dto/openai_request.go

@@ -772,11 +772,12 @@ type OpenAIResponsesRequest struct {
 	Instructions       json.RawMessage `json:"instructions,omitempty"`
 	MaxOutputTokens    uint            `json:"max_output_tokens,omitempty"`
 	Metadata           json.RawMessage `json:"metadata,omitempty"`
-	ParallelToolCalls  bool            `json:"parallel_tool_calls,omitempty"`
+	ParallelToolCalls  json.RawMessage `json:"parallel_tool_calls,omitempty"`
 	PreviousResponseID string          `json:"previous_response_id,omitempty"`
 	Reasoning          *Reasoning      `json:"reasoning,omitempty"`
 	ServiceTier        string          `json:"service_tier,omitempty"`
-	Store              bool            `json:"store,omitempty"`
+	Store              json.RawMessage `json:"store,omitempty"`
+	PromptCacheKey     json.RawMessage `json:"prompt_cache_key,omitempty"`
 	Stream             bool            `json:"stream,omitempty"`
 	Temperature        float64         `json:"temperature,omitempty"`
 	Text               json.RawMessage `json:"text,omitempty"`

+ 7 - 1
main.go

@@ -16,6 +16,7 @@ import (
 	"one-api/setting/ratio_setting"
 	"os"
 	"strconv"
+	"time"
 
 	"github.com/bytedance/gopkg/util/gopool"
 	"github.com/gin-contrib/sessions"
@@ -33,6 +34,7 @@ var buildFS embed.FS
 var indexPage []byte
 
 func main() {
+	startTime := time.Now()
 
 	err := InitResources()
 	if err != nil {
@@ -150,6 +152,10 @@ func main() {
 	if port == "" {
 		port = strconv.Itoa(*common.Port)
 	}
+
+	// Log startup success message
+	common.LogStartupSuccess(startTime, port)
+
 	err = server.Run(":" + port)
 	if err != nil {
 		common.FatalLog("failed to start HTTP server: " + err.Error())
@@ -204,4 +210,4 @@ func InitResources() error {
 		return err
 	}
 	return nil
-}
+}

+ 22 - 1
relay/channel/aws/constants.go

@@ -21,6 +21,10 @@ var awsModelIDMap = map[string]string{
 	"nova-lite-v1:0":    "amazon.nova-lite-v1:0",
 	"nova-pro-v1:0":     "amazon.nova-pro-v1:0",
 	"nova-premier-v1:0": "amazon.nova-premier-v1:0",
+	"nova-canvas-v1:0":  "amazon.nova-canvas-v1:0",
+	"nova-reel-v1:0":    "amazon.nova-reel-v1:0",
+	"nova-reel-v1:1":    "amazon.nova-reel-v1:1",
+	"nova-sonic-v1:0":   "amazon.nova-sonic-v1:0",
 }
 
 var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -82,10 +86,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
 		"apac": true,
 	},
 	"amazon.nova-premier-v1:0": {
+		"us": true,
+	},
+	"amazon.nova-canvas-v1:0": {
+		"us":   true,
+		"eu":   true,
+		"apac": true,
+	},
+	"amazon.nova-reel-v1:0": {
+		"us":   true,
+		"eu":   true,
+		"apac": true,
+	},
+	"amazon.nova-reel-v1:1": {
+		"us": true,
+	},
+	"amazon.nova-sonic-v1:0": {
 		"us":   true,
 		"eu":   true,
 		"apac": true,
-	}}
+	},
+}
 
 var awsRegionCrossModelPrefixMap = map[string]string{
 	"us": "us",

+ 6 - 8
relay/channel/task/vidu/adaptor.go

@@ -80,8 +80,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
 }
 
 func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
-	// Use the unified validation method for TaskSubmitReq with image-based action determination
-	return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
+	return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
 }
 
 func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
@@ -112,6 +111,10 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
 	switch info.Action {
 	case constant.TaskActionGenerate:
 		path = "/img2video"
+	case constant.TaskActionFirstTailGenerate:
+		path = "/start-end2video"
+	case constant.TaskActionReferenceGenerate:
+		path = "/reference2video"
 	default:
 		path = "/text2video"
 	}
@@ -187,14 +190,9 @@ func (a *TaskAdaptor) GetChannelName() string {
 // ============================
 
 func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
-	var images []string
-	if req.Image != "" {
-		images = []string{req.Image}
-	}
-
 	r := requestPayload{
 		Model:             defaultString(req.Model, "viduq1"),
-		Images:            images,
+		Images:            req.Images,
 		Prompt:            req.Prompt,
 		Duration:          defaultInt(req.Duration, 5),
 		Resolution:        defaultString(req.Size, "1080p"),

+ 13 - 6
relay/channel/volcengine/adaptor.go

@@ -9,6 +9,7 @@ import (
 	"mime/multipart"
 	"net/http"
 	"net/textproto"
+	channelconstant "one-api/constant"
 	"one-api/dto"
 	"one-api/relay/channel"
 	"one-api/relay/channel/openai"
@@ -188,20 +189,26 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
 }
 
 func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
+	// 支持自定义域名,如果未设置则使用默认域名
+	baseUrl := info.ChannelBaseUrl
+	if baseUrl == "" {
+		baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+	}
+
 	switch info.RelayMode {
 	case constant.RelayModeChatCompletions:
 		if strings.HasPrefix(info.UpstreamModelName, "bot") {
-			return fmt.Sprintf("%s/api/v3/bots/chat/completions", info.ChannelBaseUrl), nil
+			return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
 		}
-		return fmt.Sprintf("%s/api/v3/chat/completions", info.ChannelBaseUrl), nil
+		return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
 	case constant.RelayModeEmbeddings:
-		return fmt.Sprintf("%s/api/v3/embeddings", info.ChannelBaseUrl), nil
+		return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
 	case constant.RelayModeImagesGenerations:
-		return fmt.Sprintf("%s/api/v3/images/generations", info.ChannelBaseUrl), nil
+		return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
 	case constant.RelayModeImagesEdits:
-		return fmt.Sprintf("%s/api/v3/images/edits", info.ChannelBaseUrl), nil
+		return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
 	case constant.RelayModeRerank:
-		return fmt.Sprintf("%s/api/v3/rerank", info.ChannelBaseUrl), nil
+		return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
 	default:
 	}
 	return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)

+ 5 - 0
relay/channel/volcengine/constants.go

@@ -9,6 +9,11 @@ var ModelList = []string{
 	"Doubao-lite-4k",
 	"Doubao-embedding",
 	"doubao-seedream-4-0-250828",
+	"seedream-4-0-250828",
+	"doubao-seedance-1-0-pro-250528",
+	"seedance-1-0-pro-250528",
+	"doubao-seed-1-6-thinking-250715",
+	"seed-1-6-thinking-250715",
 }
 
 var ChannelName = "volcengine"

+ 3 - 4
relay/channel/xunfei/relay-xunfei.go

@@ -207,10 +207,6 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
 		return nil, nil, err
 	}
 
-	defer func() {
-		conn.Close()
-	}()
-
 	data := requestOpenAI2Xunfei(textRequest, appId, domain)
 	err = conn.WriteJSON(data)
 	if err != nil {
@@ -220,6 +216,9 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
 	dataChan := make(chan XunfeiChatResponse)
 	stopChan := make(chan bool)
 	go func() {
+		defer func() {
+			conn.Close()
+		}()
 		for {
 			_, msg, err := conn.ReadMessage()
 			if err != nil {

+ 26 - 0
relay/claude_handler.go

@@ -6,6 +6,7 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
 	relaycommon "one-api/relay/common"
 	"one-api/relay/helper"
@@ -69,6 +70,31 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
 		info.UpstreamModelName = request.Model
 	}
 
+	if info.ChannelSetting.SystemPrompt != "" {
+		if request.System == nil {
+			request.SetStringSystem(info.ChannelSetting.SystemPrompt)
+		} else if info.ChannelSetting.SystemPromptOverride {
+			common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+			if request.IsStringSystem() {
+				existing := strings.TrimSpace(request.GetStringSystem())
+				if existing == "" {
+					request.SetStringSystem(info.ChannelSetting.SystemPrompt)
+				} else {
+					request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing)
+				}
+			} else {
+				systemContents := request.ParseSystem()
+				newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
+				newSystem.SetText(info.ChannelSetting.SystemPrompt)
+				if len(systemContents) == 0 {
+					request.System = []dto.ClaudeMediaMessage{newSystem}
+				} else {
+					request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...)
+				}
+			}
+		}
+	}
+
 	var requestBody io.Reader
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)

+ 10 - 26
relay/common/relay_utils.go

@@ -79,34 +79,18 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
 		req.Images = []string{req.Image}
 	}
 
-	storeTaskRequest(c, info, action, req)
-	return nil
-}
-
-func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
-	hasPrompt, ok := requestObj.(HasPrompt)
-	if !ok {
-		return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
-	}
-
-	if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
-		return taskErr
-	}
-
-	action := constant.TaskActionTextGenerate
-	if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
+	if req.HasImage() {
 		action = constant.TaskActionGenerate
+		if info.ChannelType == constant.ChannelTypeVidu {
+			// vidu 增加 首尾帧生视频和参考图生视频
+			if len(req.Images) == 2 {
+				action = constant.TaskActionFirstTailGenerate
+			} else if len(req.Images) > 2 {
+				action = constant.TaskActionReferenceGenerate
+			}
+		}
 	}
 
-	storeTaskRequest(c, info, action, requestObj)
+	storeTaskRequest(c, info, action, req)
 	return nil
 }
-
-func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
-	var req TaskSubmitReq
-	if err := c.ShouldBindJSON(&req); err != nil {
-		return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
-	}
-
-	return ValidateTaskRequestWithImage(c, info, req)
-}

+ 27 - 0
relay/gemini_handler.go

@@ -6,6 +6,7 @@ import (
 	"io"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/dto"
 	"one-api/logger"
 	"one-api/relay/channel/gemini"
@@ -94,6 +95,32 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
 
 	adaptor.Init(info)
 
+	if info.ChannelSetting.SystemPrompt != "" {
+		if request.SystemInstructions == nil {
+			request.SystemInstructions = &dto.GeminiChatContent{
+				Parts: []dto.GeminiPart{
+					{Text: info.ChannelSetting.SystemPrompt},
+				},
+			}
+		} else if len(request.SystemInstructions.Parts) == 0 {
+			request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}
+		} else if info.ChannelSetting.SystemPromptOverride {
+			common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
+			merged := false
+			for i := range request.SystemInstructions.Parts {
+				if request.SystemInstructions.Parts[i].Text == "" {
+					continue
+				}
+				request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text
+				merged = true
+				break
+			}
+			if !merged {
+				request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...)
+			}
+		}
+	}
+
 	// Clean up empty system instruction
 	if request.SystemInstructions != nil {
 		hasContent := false

+ 9 - 0
web/jsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*"]
+}

+ 2 - 2
web/src/components/common/markdown/MarkdownRenderer.jsx

@@ -181,8 +181,8 @@ export function PreCode(props) {
                 e.preventDefault();
                 e.stopPropagation();
                 if (ref.current) {
-                  const code =
-                    ref.current.querySelector('code')?.innerText ?? '';
+                  const codeElement = ref.current.querySelector('code');
+                  const code = codeElement?.textContent ?? '';
                   copy(code).then((success) => {
                     if (success) {
                       Toast.success(t('代码已复制到剪贴板'));

+ 87 - 83
web/src/components/layout/headerbar/UserArea.jsx

@@ -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 React from 'react';
+import React, { useRef } from 'react';
 import { Link } from 'react-router-dom';
 import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
 import { ChevronDown } from 'lucide-react';
@@ -39,6 +39,7 @@ const UserArea = ({
   navigate,
   t,
 }) => {
+  const dropdownRef = useRef(null);
   if (isLoading) {
     return (
       <SkeletonWrapper
@@ -52,90 +53,93 @@ const UserArea = ({
 
   if (userState.user) {
     return (
-      <Dropdown
-        position='bottomRight'
-        render={
-          <Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
-            <Dropdown.Item
-              onClick={() => {
-                navigate('/console/personal');
-              }}
-              className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
-            >
-              <div className='flex items-center gap-2'>
-                <IconUserSetting
-                  size='small'
-                  className='text-gray-500 dark:text-gray-400'
-                />
-                <span>{t('个人设置')}</span>
-              </div>
-            </Dropdown.Item>
-            <Dropdown.Item
-              onClick={() => {
-                navigate('/console/token');
-              }}
-              className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
-            >
-              <div className='flex items-center gap-2'>
-                <IconKey
-                  size='small'
-                  className='text-gray-500 dark:text-gray-400'
-                />
-                <span>{t('令牌管理')}</span>
-              </div>
-            </Dropdown.Item>
-            <Dropdown.Item
-              onClick={() => {
-                navigate('/console/topup');
-              }}
-              className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
-            >
-              <div className='flex items-center gap-2'>
-                <IconCreditCard
-                  size='small'
-                  className='text-gray-500 dark:text-gray-400'
-                />
-                <span>{t('钱包管理')}</span>
-              </div>
-            </Dropdown.Item>
-            <Dropdown.Item
-              onClick={logout}
-              className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
-            >
-              <div className='flex items-center gap-2'>
-                <IconExit
-                  size='small'
-                  className='text-gray-500 dark:text-gray-400'
-                />
-                <span>{t('退出')}</span>
-              </div>
-            </Dropdown.Item>
-          </Dropdown.Menu>
-        }
-      >
-        <Button
-          theme='borderless'
-          type='tertiary'
-          className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
+      <div className='relative' ref={dropdownRef}>
+        <Dropdown
+          position='bottomRight'
+          getPopupContainer={() => dropdownRef.current}
+          render={
+            <Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/personal');
+                }}
+                className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
+              >
+                <div className='flex items-center gap-2'>
+                  <IconUserSetting
+                    size='small'
+                    className='text-gray-500 dark:text-gray-400'
+                  />
+                  <span>{t('个人设置')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/token');
+                }}
+                className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
+              >
+                <div className='flex items-center gap-2'>
+                  <IconKey
+                    size='small'
+                    className='text-gray-500 dark:text-gray-400'
+                  />
+                  <span>{t('令牌管理')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item
+                onClick={() => {
+                  navigate('/console/topup');
+                }}
+                className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
+              >
+                <div className='flex items-center gap-2'>
+                  <IconCreditCard
+                    size='small'
+                    className='text-gray-500 dark:text-gray-400'
+                  />
+                  <span>{t('钱包管理')}</span>
+                </div>
+              </Dropdown.Item>
+              <Dropdown.Item
+                onClick={logout}
+                className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
+              >
+                <div className='flex items-center gap-2'>
+                  <IconExit
+                    size='small'
+                    className='text-gray-500 dark:text-gray-400'
+                  />
+                  <span>{t('退出')}</span>
+                </div>
+              </Dropdown.Item>
+            </Dropdown.Menu>
+          }
         >
-          <Avatar
-            size='extra-small'
-            color={stringToColor(userState.user.username)}
-            className='mr-1'
+          <Button
+            theme='borderless'
+            type='tertiary'
+            className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
           >
-            {userState.user.username[0].toUpperCase()}
-          </Avatar>
-          <span className='hidden md:inline'>
-            <Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
-              {userState.user.username}
-            </Typography.Text>
-          </span>
-          <ChevronDown
-            size={14}
-            className='text-xs text-semi-color-text-2 dark:text-gray-400'
-          />
-        </Button>
-      </Dropdown>
+            <Avatar
+              size='extra-small'
+              color={stringToColor(userState.user.username)}
+              className='mr-1'
+            >
+              {userState.user.username[0].toUpperCase()}
+            </Avatar>
+            <span className='hidden md:inline'>
+              <Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
+                {userState.user.username}
+              </Typography.Text>
+            </span>
+            <ChevronDown
+              size={14}
+              className='text-xs text-semi-color-text-2 dark:text-gray-400'
+            />
+          </Button>
+        </Dropdown>
+      </div>
     );
   } else {
     const showRegisterButton = !isSelfUseMode;

+ 8 - 1
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
 import { StatusContext } from '../../../../context/Status';
 import { UserContext } from '../../../../context/User';
 import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
+import { useSidebar } from '../../../../hooks/common/useSidebar';
 
 const NotificationSettings = ({
   t,
@@ -97,6 +98,9 @@ const NotificationSettings = ({
     isSidebarModuleAllowed,
   } = useUserPermissions();
 
+  // 使用useSidebar钩子获取刷新方法
+  const { refreshUserConfig } = useSidebar();
+
   // 左侧边栏设置处理函数
   const handleSectionChange = (sectionKey) => {
     return (checked) => {
@@ -132,6 +136,9 @@ const NotificationSettings = ({
       });
       if (res.data.success) {
         showSuccess(t('侧边栏设置保存成功'));
+
+        // 刷新useSidebar钩子中的用户配置,实现实时更新
+        await refreshUserConfig();
       } else {
         showError(res.data.message);
       }
@@ -334,7 +341,7 @@ const NotificationSettings = ({
                 loading={sidebarLoading}
                 className='!rounded-lg'
               >
-                {t('保存边栏设置')}
+                {t('保存设置')}
               </Button>
             </>
           ) : (

+ 62 - 8
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -87,6 +87,26 @@ const REGION_EXAMPLE = {
   'claude-3-5-sonnet-20240620': 'europe-west1',
 };
 
+// 支持并且已适配通过接口获取模型列表的渠道类型
+const MODEL_FETCHABLE_TYPES = new Set([
+  1,
+  4,
+  14,
+  34,
+  17,
+  26,
+  24,
+  47,
+  25,
+  20,
+  23,
+  31,
+  35,
+  40,
+  42,
+  48,
+]);
+
 function type2secretPrompt(type) {
   // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
   switch (type) {
@@ -260,7 +280,7 @@ const EditChannelModal = (props) => {
     pass_through_body_enabled: false,
     system_prompt: '',
   });
-  const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示)
+  const showApiConfigCard = true; // 控制是否显示 API 配置卡片
   const getInitValues = () => ({ ...originInputs });
 
   // 处理渠道额外设置的更新
@@ -367,6 +387,10 @@ const EditChannelModal = (props) => {
         case 36:
           localModels = ['suno_music', 'suno_lyrics'];
           break;
+        case 45:
+          localModels = getChannelModels(value);
+          setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
+          break;
         default:
           localModels = getChannelModels(value);
           break;
@@ -869,6 +893,10 @@ const EditChannelModal = (props) => {
       showInfo(t('请至少选择一个模型!'));
       return;
     }
+    if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
+      showInfo(t('请输入API地址!'));
+      return;
+    }
     if (
       localInputs.model_mapping &&
       localInputs.model_mapping !== '' &&
@@ -1876,6 +1904,30 @@ const EditChannelModal = (props) => {
                         />
                       </div>
                     )}
+
+                    {inputs.type === 45 && (
+                        <div>
+                          <Form.Select
+                              field='base_url'
+                              label={t('API地址')}
+                              placeholder={t('请选择API地址')}
+                              onChange={(value) =>
+                                  handleInputChange('base_url', value)
+                              }
+                              optionList={[
+                                {
+                                  value: 'https://ark.cn-beijing.volces.com',
+                                  label: 'https://ark.cn-beijing.volces.com'
+                                },
+                                {
+                                  value: 'https://ark.ap-southeast.bytepluses.com',
+                                  label: 'https://ark.ap-southeast.bytepluses.com'
+                                }
+                              ]}
+                              defaultValue='https://ark.cn-beijing.volces.com'
+                          />
+                        </div>
+                    )}
                     </Card>
                   </div>
                 )}
@@ -1961,13 +2013,15 @@ const EditChannelModal = (props) => {
                         >
                           {t('填入所有模型')}
                         </Button>
-                        <Button
-                          size='small'
-                          type='tertiary'
-                          onClick={() => fetchUpstreamModelList('models')}
-                        >
-                          {t('获取模型列表')}
-                        </Button>
+                        {MODEL_FETCHABLE_TYPES.has(inputs.type) && (
+                          <Button
+                            size='small'
+                            type='tertiary'
+                            onClick={() => fetchUpstreamModelList('models')}
+                          >
+                            {t('获取模型列表')}
+                          </Button>
+                        )}
                         <Button
                           size='small'
                           type='warning'

+ 42 - 1
web/src/components/table/channels/modals/MultiKeyManageModal.jsx

@@ -247,6 +247,32 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
     }
   };
 
+  // Delete a specific key
+  const handleDeleteKey = async (keyIndex) => {
+    const operationId = `delete_${keyIndex}`;
+    setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
+
+    try {
+      const res = await API.post('/api/channel/multi_key/manage', {
+        channel_id: channel.id,
+        action: 'delete_key',
+        key_index: keyIndex,
+      });
+
+      if (res.data.success) {
+        showSuccess(t('密钥已删除'));
+        await loadKeyStatus(currentPage, pageSize); // Reload current page
+        onRefresh && onRefresh(); // Refresh parent component
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('删除密钥失败'));
+    } finally {
+      setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
+    }
+  };
+
   // Handle page change
   const handlePageChange = (page) => {
     setCurrentPage(page);
@@ -384,7 +410,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
       title: t('操作'),
       key: 'action',
       fixed: 'right',
-      width: 100,
+      width: 150,
       render: (_, record) => (
         <Space>
           {record.status === 1 ? (
@@ -406,6 +432,21 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
               {t('启用')}
             </Button>
           )}
+          <Popconfirm
+            title={t('确定要删除此密钥吗?')}
+            content={t('此操作不可撤销,将永久删除该密钥')}
+            onConfirm={() => handleDeleteKey(record.index)}
+            okType={'danger'}
+            position={'topRight'}
+          >
+            <Button
+              type='danger'
+              size='small'
+              loading={operationLoading[`delete_${record.index}`]}
+            >
+              {t('删除')}
+            </Button>
+          </Popconfirm>
         </Space>
       ),
     },

+ 7 - 0
web/src/components/table/mj-logs/MjLogsFilters.jsx

@@ -21,6 +21,8 @@ import React from 'react';
 import { Button, Form } from '@douyinfe/semi-ui';
 import { IconSearch } from '@douyinfe/semi-icons';
 
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
 const MjLogsFilters = ({
   formInitValues,
   setFormApi,
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
               showClear
               pure
               size='small'
+              presets={DATE_RANGE_PRESETS.map(preset => ({
+                text: t(preset.text),
+                start: preset.start(),
+                end: preset.end()
+              }))}
             />
           </div>
 

+ 18 - 3
web/src/components/table/task-logs/TaskLogsColumnDefs.jsx

@@ -35,8 +35,9 @@ import {
   Sparkles,
 } from 'lucide-react';
 import {
-  TASK_ACTION_GENERATE,
-  TASK_ACTION_TEXT_GENERATE,
+  TASK_ACTION_FIRST_TAIL_GENERATE,
+  TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
+  TASK_ACTION_TEXT_GENERATE
 } from '../../../constants/common.constant';
 import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
 
@@ -111,6 +112,18 @@ const renderType = (type, t) => {
           {t('文生视频')}
         </Tag>
       );
+    case TASK_ACTION_FIRST_TAIL_GENERATE:
+      return (
+        <Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
+          {t('首尾生视频')}
+        </Tag>
+      );
+    case TASK_ACTION_REFERENCE_GENERATE:
+      return (
+        <Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
+          {t('参照生视频')}
+        </Tag>
+      );
     default:
       return (
         <Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
@@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({
         // 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
         const isVideoTask =
           record.action === TASK_ACTION_GENERATE ||
-          record.action === TASK_ACTION_TEXT_GENERATE;
+          record.action === TASK_ACTION_TEXT_GENERATE ||
+          record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
+          record.action === TASK_ACTION_REFERENCE_GENERATE;
         const isSuccess = record.status === 'SUCCESS';
         const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
         if (isSuccess && isVideoTask && isUrl) {

+ 7 - 0
web/src/components/table/task-logs/TaskLogsFilters.jsx

@@ -21,6 +21,8 @@ import React from 'react';
 import { Button, Form } from '@douyinfe/semi-ui';
 import { IconSearch } from '@douyinfe/semi-icons';
 
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
 const TaskLogsFilters = ({
   formInitValues,
   setFormApi,
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
               showClear
               pure
               size='small'
+              presets={DATE_RANGE_PRESETS.map(preset => ({
+                text: t(preset.text),
+                start: preset.start(),
+                end: preset.end()
+              }))}
             />
           </div>
 

+ 7 - 0
web/src/components/table/usage-logs/UsageLogsFilters.jsx

@@ -21,6 +21,8 @@ import React from 'react';
 import { Button, Form } from '@douyinfe/semi-ui';
 import { IconSearch } from '@douyinfe/semi-icons';
 
+import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
+
 const LogsFilters = ({
   formInitValues,
   setFormApi,
@@ -55,6 +57,11 @@ const LogsFilters = ({
               showClear
               pure
               size='small'
+              presets={DATE_RANGE_PRESETS.map(preset => ({
+                text: t(preset.text),
+                start: preset.start(),
+                end: preset.end()
+              }))}
             />
           </div>
 

+ 2 - 0
web/src/constants/common.constant.js

@@ -40,3 +40,5 @@ export const API_ENDPOINTS = [
 
 export const TASK_ACTION_GENERATE = 'generate';
 export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
+export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate';
+export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate';

+ 49 - 0
web/src/constants/console.constants.js

@@ -0,0 +1,49 @@
+/*
+Copyright (C) 2025 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
+*/
+
+import dayjs from 'dayjs';
+
+// ========== 日期预设常量 ==========
+export const DATE_RANGE_PRESETS = [
+  {
+    text: '今天',
+    start: () => dayjs().startOf('day').toDate(),
+    end: () => dayjs().endOf('day').toDate()
+  },
+  {
+    text: '近 7 天',
+    start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
+    end: () => dayjs().endOf('day').toDate()
+  },
+  {
+    text: '本周',
+    start: () => dayjs().startOf('week').toDate(),
+    end: () => dayjs().endOf('week').toDate()
+  },
+  {
+    text: '近 30 天',
+    start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
+    end: () => dayjs().endOf('day').toDate()
+  },
+  {
+    text: '本月',
+    start: () => dayjs().startOf('month').toDate(),
+    end: () => dayjs().endOf('month').toDate()
+  },
+];

+ 8 - 7
web/src/helpers/api.js

@@ -118,7 +118,6 @@ export const buildApiPayload = (
     model: inputs.model,
     group: inputs.group,
     messages: processedMessages,
-    group: inputs.group,
     stream: inputs.stream,
   };
 
@@ -132,13 +131,15 @@ export const buildApiPayload = (
     seed: 'seed',
   };
 
+
   Object.entries(parameterMappings).forEach(([key, param]) => {
-    if (
-      parameterEnabled[key] &&
-      inputs[param] !== undefined &&
-      inputs[param] !== null
-    ) {
-      payload[param] = inputs[param];
+    const enabled = parameterEnabled[key];
+    const value = inputs[param];
+    const hasValue = value !== undefined && value !== null;
+
+
+    if (enabled && hasValue) {
+      payload[param] = value;
     }
   });
 

+ 11 - 7
web/src/helpers/utils.jsx

@@ -75,13 +75,17 @@ export async function copy(text) {
     await navigator.clipboard.writeText(text);
   } catch (e) {
     try {
-      // 构建input 执行 复制命令
-      var _input = window.document.createElement('input');
-      _input.value = text;
-      window.document.body.appendChild(_input);
-      _input.select();
-      window.document.execCommand('Copy');
-      window.document.body.removeChild(_input);
+      // 构建 textarea 执行复制命令,保留多行文本格式
+      const textarea = window.document.createElement('textarea');
+      textarea.value = text;
+      textarea.setAttribute('readonly', '');
+      textarea.style.position = 'fixed';
+      textarea.style.left = '-9999px';
+      textarea.style.top = '-9999px';
+      window.document.body.appendChild(textarea);
+      textarea.select();
+      window.document.execCommand('copy');
+      window.document.body.removeChild(textarea);
     } catch (e) {
       okay = false;
       console.error(e);

+ 23 - 1
web/src/hooks/common/useSidebar.js

@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
 import { StatusContext } from '../../context/Status';
 import { API } from '../../helpers';
 
+// 创建一个全局事件系统来同步所有useSidebar实例
+const sidebarEventTarget = new EventTarget();
+const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
+
 export const useSidebar = () => {
   const [statusState] = useContext(StatusContext);
   const [userConfig, setUserConfig] = useState(null);
@@ -124,9 +128,12 @@ export const useSidebar = () => {
 
   // 刷新用户配置的方法(供外部调用)
   const refreshUserConfig = async () => {
-    if (Object.keys(adminConfig).length > 0) {
+     if (Object.keys(adminConfig).length > 0) {
       await loadUserConfig();
     }
+
+    // 触发全局刷新事件,通知所有useSidebar实例更新
+    sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
   };
 
   // 加载用户配置
@@ -137,6 +144,21 @@ export const useSidebar = () => {
     }
   }, [adminConfig]);
 
+  // 监听全局刷新事件
+  useEffect(() => {
+    const handleRefresh = () => {
+      if (Object.keys(adminConfig).length > 0) {
+        loadUserConfig();
+      }
+    };
+
+    sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+
+    return () => {
+      sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
+    };
+  }, [adminConfig]);
+
   // 计算最终的显示配置
   const finalConfig = useMemo(() => {
     const result = {};

+ 1 - 1
web/src/hooks/dashboard/useDashboardStats.jsx

@@ -102,7 +102,7 @@ export const useDashboardStats = (
           },
           {
             title: t('统计Tokens'),
-            value: isNaN(consumeTokens) ? 0 : consumeTokens,
+            value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
             icon: <IconTextStroked />,
             avatarColor: 'pink',
             trendData: trendData.tokens,

+ 9 - 0
web/src/i18n/locales/en.json

@@ -1889,6 +1889,10 @@
   "确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
   "此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
   "删除自动禁用密钥": "Delete auto disabled keys",
+  "确定要删除此密钥吗?": "Are you sure you want to delete this key?",
+  "此操作不可撤销,将永久删除该密钥": "This operation cannot be undone, and the key will be permanently deleted.",
+  "密钥已删除": "Key has been deleted",
+  "删除密钥失败": "Failed to delete key",
   "图标": "Icon",
   "模型图标": "Model icon",
   "请输入图标名称": "Please enter the icon name",
@@ -2095,6 +2099,11 @@
   "优惠": "Discount",
   "折": "% off",
   "节省": "Save",
+  "今天": "Today",
+  "近 7 天": "Last 7 Days",
+  "本周": "This Week",
+  "本月": "This Month",
+  "近 30 天": "Last 30 Days",
   "代理设置": "Proxy Settings",
   "更新Worker设置": "Update Worker Settings",
   "SSRF防护设置": "SSRF Protection Settings",

+ 6 - 0
web/vite.config.js

@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
 import react from '@vitejs/plugin-react';
 import { defineConfig, transformWithEsbuild } from 'vite';
 import pkg from '@douyinfe/vite-plugin-semi';
+import path from 'path';
 const { vitePluginSemi } = pkg;
 
 // https://vitejs.dev/config/
 export default defineConfig({
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, './src'),
+    },
+  },
   plugins: [
     {
       name: 'treat-js-files-as-jsx',