diff --git a/.github/workflows/docker-image-amd64.yml b/.github/workflows/docker-image-alpha.yml similarity index 72% rename from .github/workflows/docker-image-amd64.yml rename to .github/workflows/docker-image-alpha.yml index a823151c..c02bd409 100644 --- a/.github/workflows/docker-image-amd64.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -1,14 +1,15 @@ -name: Publish Docker image (amd64) +name: Publish Docker image (alpha) on: push: - tags: - - '*' + branches: + - alpha workflow_dispatch: inputs: name: - description: 'reason' + description: "reason" required: false + jobs: push_to_registries: name: Push Docker image to multiple registries @@ -22,7 +23,7 @@ jobs: - name: Save version info run: | - git describe --tags > VERSION + echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION - name: Log in to Docker Hub uses: docker/login-action@v3 @@ -37,6 +38,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -44,11 +48,15 @@ jobs: images: | calciumion/new-api ghcr.io/${{ github.repository }} + tags: | + type=raw,value=alpha + type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}} - name: Build and push Docker images uses: docker/build-push-action@v5 with: context: . + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index d7468c8e..8e4656aa 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -1,14 +1,9 @@ -name: Publish Docker image (arm64) +name: Publish Docker image (Multi Registries) on: push: tags: - '*' - workflow_dispatch: - inputs: - name: - description: 'reason' - required: false jobs: push_to_registries: name: Push Docker image to multiple registries diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 3ddabc6d..c87fcfce 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -15,16 +20,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index ccc480bf..3210065b 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -15,16 +20,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index f9500718..de3d83d5 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -3,6 +3,11 @@ permissions: contents: write on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false push: tags: - '*' @@ -18,16 +23,16 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-node@v3 + - uses: oven-sh/setup-bun@v2 with: - node-version: 18 + bun-version: latest - name: Build Frontend env: CI: "" run: | cd web - npm install - REACT_APP_VERSION=$(git describe --tags) npm run build + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build cd .. - name: Set up Go uses: actions/setup-go@v3 diff --git a/common/redis.go b/common/redis.go index 49d3ec78..ba35331a 100644 --- a/common/redis.go +++ b/common/redis.go @@ -92,12 +92,12 @@ func RedisDel(key string) error { return RDB.Del(ctx, key).Err() } -func RedisHDelObj(key string) error { +func RedisDelKey(key string) error { if DebugEnabled { - SysLog(fmt.Sprintf("Redis HDEL: key=%s", key)) + SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) } ctx := context.Background() - return RDB.HDel(ctx, key).Err() + return RDB.Del(ctx, key).Err() } func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { diff --git a/controller/channel-test.go b/controller/channel-test.go index d1cb4093..f9c7bf7b 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest { } else { testRequest.MaxTokens = 10 } - content, _ := json.Marshal("hi") + testMessage := dto.Message{ Role: "user", - Content: content, + Content: "hi", } testRequest.Model = model testRequest.Messages = append(testRequest.Messages, testMessage) @@ -271,6 +271,13 @@ func testAllChannels(notify bool) error { disableThreshold = 10000000 // a impossible value } gopool.Go(func() { + // 使用 defer 确保无论如何都会重置运行状态,防止死锁 + defer func() { + testAllChannelsLock.Lock() + testAllChannelsRunning = false + testAllChannelsLock.Unlock() + }() + for _, channel := range channels { isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() @@ -305,9 +312,7 @@ func testAllChannels(notify bool) error { channel.UpdateResponseTime(milliseconds) time.Sleep(common.RequestInterval) } - testAllChannelsLock.Lock() - testAllChannelsRunning = false - testAllChannelsLock.Unlock() + if notify { service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") } diff --git a/controller/channel.go b/controller/channel.go index a31e1f47..a4ef87c3 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) { }) return } + +func GetTagModels(c *gin.Context) { + tag := c.Query("tag") + if tag == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + + channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var longestModels string + maxLength := 0 + + // Find the longest models string among all channels with the given tag + for _, channel := range channels { + if channel.Models != "" { + currentModels := strings.Split(channel.Models, ",") + if len(currentModels) > maxLength { + maxLength = len(currentModels) + longestModels = channel.Models + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": longestModels, + }) + return +} diff --git a/controller/misc.go b/controller/misc.go index 4d265c3f..8fa8e8f6 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -6,6 +6,7 @@ import ( "net/http" "one-api/common" "one-api/constant" + "one-api/middleware" "one-api/model" "one-api/setting" "one-api/setting/operation_setting" @@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) { }) return } + // 获取HTTP统计信息 + httpStats := middleware.GetStats() c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Server is running", + "success": true, + "message": "Server is running", + "http_stats": httpStats, }) return } func GetStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) { "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "setup": constant.Setup, + "api_info": setting.GetApiInfo(), + "announcements": setting.GetAnnouncements(), + "faq": setting.GetFAQ(), }, }) return diff --git a/controller/option.go b/controller/option.go index 250f16bb..b52012fd 100644 --- a/controller/option.go +++ b/controller/option.go @@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) { }) return } - + case "ApiInfo": + err = setting.ValidateApiInfo(option.Value) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "Announcements": + err = setting.ValidateConsoleSettings(option.Value, "Announcements") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "FAQ": + err = setting.ValidateConsoleSettings(option.Value, "FAQ") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } } err = model.UpdateOption(option.Key, option.Value) if err != nil { diff --git a/controller/topup.go b/controller/topup.go index 4654b6ea..951b2cf2 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) { payType = "wxpay" } callBackAddress := service.GetCallbackAddress() - returnUrl, _ := url.Parse(setting.ServerAddress + "/log") + returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log") notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 00000000..6ceaa1f3 --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,169 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +type UptimeKumaMonitor struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type UptimeKumaGroup struct { + ID int `json:"id"` + Name string `json:"name"` + Weight int `json:"weight"` + MonitorList []UptimeKumaMonitor `json:"monitorList"` +} + +type UptimeKumaHeartbeat struct { + Status int `json:"status"` + Time string `json:"time"` + Msg string `json:"msg"` + Ping *float64 `json:"ping"` +} + +type UptimeKumaStatusResponse struct { + PublicGroupList []UptimeKumaGroup `json:"publicGroupList"` +} + +type UptimeKumaHeartbeatResponse struct { + HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` +} + +type MonitorStatus struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` +} + +var ( + ErrUpstreamNon200 = errors.New("upstream non-200") + ErrTimeout = errors.New("context deadline exceeded") +) + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return ErrTimeout + } + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ErrUpstreamNon200 + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +func GetUptimeKumaStatus(c *gin.Context) { + common.OptionMapRWMutex.RLock() + uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"] + slug := common.OptionMap["UptimeKumaSlug"] + common.OptionMapRWMutex.RUnlock() + + if uptimeKumaUrl == "" || slug == "" { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": []MonitorStatus{}, + }) + return + } + + uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/") + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + client := &http.Client{} + + statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug) + heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug) + + var ( + statusData UptimeKumaStatusResponse + heartbeatData UptimeKumaHeartbeatResponse + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + return getAndDecode(gCtx, client, statusPageUrl, &statusData) + }) + + g.Go(func() error { + return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData) + }) + + if err := g.Wait(); err != nil { + switch err { + case ErrUpstreamNon200: + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "上游接口出现问题", + }) + case ErrTimeout: + c.JSON(http.StatusRequestTimeout, gin.H{ + "success": false, + "message": "请求上游接口超时", + }) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + } + return + } + + var monitors []MonitorStatus + for _, group := range statusData.PublicGroupList { + for _, monitor := range group.MonitorList { + monitorStatus := MonitorStatus{ + Name: monitor.Name, + Uptime: 0.0, + Status: 0, + } + + uptimeKey := fmt.Sprintf("%d_24", monitor.ID) + if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists { + monitorStatus.Uptime = uptime + } + + heartbeatKey := fmt.Sprintf("%d", monitor.ID) + if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 { + latestHeartbeat := heartbeats[0] + monitorStatus.Status = latestHeartbeat.Status + } + + monitors = append(monitors, monitorStatus) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": monitors, + }) +} \ No newline at end of file diff --git a/dto/claude.go b/dto/claude.go index 36dfc02e..4d24bc70 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -1,6 +1,9 @@ package dto -import "encoding/json" +import ( + "encoding/json" + "one-api/common" +) type ClaudeMetadata struct { UserId string `json:"user_id"` @@ -20,11 +23,11 @@ type ClaudeMediaMessage struct { Delta string `json:"delta,omitempty"` CacheControl json.RawMessage `json:"cache_control,omitempty"` // tool_calls - Id string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input any `json:"input,omitempty"` - Content json.RawMessage `json:"content,omitempty"` - ToolUseId string `json:"tool_use_id,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content any `json:"content,omitempty"` + ToolUseId string `json:"tool_use_id,omitempty"` } func (c *ClaudeMediaMessage) SetText(s string) { @@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string { } func (c *ClaudeMediaMessage) IsStringContent() bool { - var content string - return json.Unmarshal(c.Content, &content) == nil + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + if ok { + return true + } + return false } func (c *ClaudeMediaMessage) GetStringContent() string { - var content string - if err := json.Unmarshal(c.Content, &content); err == nil { - return content + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string { } func (c *ClaudeMediaMessage) SetContent(content any) { - jsonContent, _ := json.Marshal(content) - c.Content = jsonContent + c.Content = content } func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { - var mediaContent []ClaudeMediaMessage - if err := json.Unmarshal(c.Content, &mediaContent); err == nil { - return mediaContent - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content) + return mediaContent } type ClaudeMessageSource struct { @@ -82,14 +105,36 @@ type ClaudeMessage struct { } func (c *ClaudeMessage) IsStringContent() bool { + if c.Content == nil { + return false + } _, ok := c.Content.(string) return ok } func (c *ClaudeMessage) GetStringContent() string { - if c.IsStringContent() { - return c.Content.(string) + if c.Content == nil { + return "" } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + return "" } @@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) { } func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.Content) - var contentList []ClaudeMediaMessage - err := json.Unmarshal(jsonContent, &contentList) - if err != nil { - return make([]ClaudeMediaMessage, 0), err - } - return contentList, nil + return common.Any2Type[[]ClaudeMediaMessage](c.Content) } type Tool struct { @@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) { } func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { - // map content to []ClaudeMediaMessage - // parse to json - jsonContent, _ := json.Marshal(c.System) - var contentList []ClaudeMediaMessage - if err := json.Unmarshal(jsonContent, &contentList); err == nil { - return contentList - } - return make([]ClaudeMediaMessage, 0) + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System) + return mediaContent } type ClaudeError struct { diff --git a/dto/openai_request.go b/dto/openai_request.go index 08b1a747..a51dffd8 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -37,11 +37,11 @@ type GeneralOpenAIRequest struct { Input any `json:"input,omitempty"` Instruction string `json:"instruction,omitempty"` Size string `json:"size,omitempty"` - Functions any `json:"functions,omitempty"` + Functions json.RawMessage `json:"functions,omitempty"` FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` PresencePenalty float64 `json:"presence_penalty,omitempty"` ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - EncodingFormat any `json:"encoding_format,omitempty"` + EncodingFormat json.RawMessage `json:"encoding_format,omitempty"` Seed float64 `json:"seed,omitempty"` ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` Tools []ToolCallRequest `json:"tools,omitempty"` @@ -50,13 +50,13 @@ type GeneralOpenAIRequest struct { LogProbs bool `json:"logprobs,omitempty"` TopLogProbs int `json:"top_logprobs,omitempty"` Dimensions int `json:"dimensions,omitempty"` - Modalities any `json:"modalities,omitempty"` - Audio any `json:"audio,omitempty"` + Modalities json.RawMessage `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` EnableThinking any `json:"enable_thinking,omitempty"` // ali - ExtraBody any `json:"extra_body,omitempty"` + ExtraBody json.RawMessage `json:"extra_body,omitempty"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` // OpenRouter Params - Usage json.RawMessage `json:"usage,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"` } @@ -108,16 +108,16 @@ func (r *GeneralOpenAIRequest) ParseInput() []string { } type Message struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - Name *string `json:"name,omitempty"` - Prefix *bool `json:"prefix,omitempty"` - ReasoningContent string `json:"reasoning_content,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls json.RawMessage `json:"tool_calls,omitempty"` - ToolCallId string `json:"tool_call_id,omitempty"` - parsedContent []MediaContent - parsedStringContent *string + Role string `json:"role"` + Content any `json:"content"` + Name *string `json:"name,omitempty"` + Prefix *bool `json:"prefix,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + parsedContent []MediaContent + //parsedStringContent *string } type MediaContent struct { @@ -133,21 +133,50 @@ type MediaContent struct { func (m *MediaContent) GetImageMedia() *MessageImageUrl { if m.ImageUrl != nil { - return m.ImageUrl.(*MessageImageUrl) + if _, ok := m.ImageUrl.(*MessageImageUrl); ok { + return m.ImageUrl.(*MessageImageUrl) + } + if itemMap, ok := m.ImageUrl.(map[string]any); ok { + out := &MessageImageUrl{ + Url: common.Interface2String(itemMap["url"]), + Detail: common.Interface2String(itemMap["detail"]), + MimeType: common.Interface2String(itemMap["mime_type"]), + } + return out + } } return nil } func (m *MediaContent) GetInputAudio() *MessageInputAudio { if m.InputAudio != nil { - return m.InputAudio.(*MessageInputAudio) + if _, ok := m.InputAudio.(*MessageInputAudio); ok { + return m.InputAudio.(*MessageInputAudio) + } + if itemMap, ok := m.InputAudio.(map[string]any); ok { + out := &MessageInputAudio{ + Data: common.Interface2String(itemMap["data"]), + Format: common.Interface2String(itemMap["format"]), + } + return out + } } return nil } func (m *MediaContent) GetFile() *MessageFile { if m.File != nil { - return m.File.(*MessageFile) + if _, ok := m.File.(*MessageFile); ok { + return m.File.(*MessageFile) + } + if itemMap, ok := m.File.(map[string]any); ok { + out := &MessageFile{ + FileName: common.Interface2String(itemMap["file_name"]), + FileData: common.Interface2String(itemMap["file_data"]), + FileId: common.Interface2String(itemMap["file_id"]), + } + return out + } } return nil } @@ -213,6 +242,186 @@ func (m *Message) SetToolCalls(toolCalls any) { } func (m *Message) StringContent() string { + switch m.Content.(type) { + case string: + return m.Content.(string) + case []any: + var contentStr string + for _, contentItem := range m.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + m.Content = content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + m.Content = content + m.parsedContent = content +} + +func (m *Message) IsStringContent() bool { + _, ok := m.Content.(string) + if ok { + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.Content == nil { + return nil + } + if len(m.parsedContent) > 0 { + return m.parsedContent + } + + var contentList []MediaContent + // 先尝试解析为字符串 + content, ok := m.Content.(string) + if ok { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: content, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + //var arrayContent []map[string]interface{} + + arrayContent, ok := m.Content.([]any) + if !ok { + return contentList + } + + for _, contentItemAny := range arrayContent { + mediaItem, ok := contentItemAny.(MediaContent) + if ok { + contentList = append(contentList, mediaItem) + continue + } + + contentItem, ok := contentItemAny.(map[string]any) + if !ok { + continue + } + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +} + +// old code +/*func (m *Message) StringContent() string { if m.parsedStringContent != nil { return *m.parsedStringContent } @@ -383,7 +592,7 @@ func (m *Message) ParseContent() []MediaContent { m.parsedContent = contentList } return contentList -} +}*/ type WebSearchOptions struct { SearchContextSize string `json:"search_context_size,omitempty"` diff --git a/go.mod b/go.mod index ce768bf3..9479ba55 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b - github.com/bytedance/sonic v1.11.6 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/sessions v0.0.5 @@ -25,10 +24,10 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/joho/godotenv v1.5.1 github.com/pkg/errors v0.9.1 - github.com/pkoukk/tiktoken-go v0.1.7 github.com/samber/lo v1.39.0 github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.4.0 + github.com/tiktoken-go/tokenizer v0.6.2 golang.org/x/crypto v0.35.0 golang.org/x/image v0.23.0 golang.org/x/net v0.35.0 @@ -43,12 +42,13 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect diff --git a/go.sum b/go.sum index 2bd81fa3..71dd83c2 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= @@ -167,8 +167,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= -github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -197,6 +195,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= +github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= diff --git a/makefile b/makefile index 5042723c..cbc4ea6a 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ all: build-frontend start-backend build-frontend: @echo "Building frontend..." - @cd $(FRONTEND_DIR) && npm install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) npm run build + @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build start-backend: @echo "Starting backend dev server..." diff --git a/middleware/auth.go b/middleware/auth.go index ce86bb36..f387029f 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) { if skKey != "" { c.Request.Header.Set("Authorization", "Bearer "+skKey) } + // 从x-goog-api-key header中获取key + xGoogKey := c.Request.Header.Get("x-goog-api-key") + if xGoogKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+xGoogKey) + } } key := c.Request.Header.Get("Authorization") parts := make([]string, 0) diff --git a/middleware/stats.go b/middleware/stats.go new file mode 100644 index 00000000..1c97983f --- /dev/null +++ b/middleware/stats.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "sync/atomic" + + "github.com/gin-gonic/gin" +) + +// HTTPStats 存储HTTP统计信息 +type HTTPStats struct { + activeConnections int64 +} + +var globalStats = &HTTPStats{} + +// StatsMiddleware 统计中间件 +func StatsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 增加活跃连接数 + atomic.AddInt64(&globalStats.activeConnections, 1) + + // 确保在请求结束时减少连接数 + defer func() { + atomic.AddInt64(&globalStats.activeConnections, -1) + }() + + c.Next() + } +} + +// StatsInfo 统计信息结构 +type StatsInfo struct { + ActiveConnections int64 `json:"active_connections"` +} + +// GetStats 获取统计信息 +func GetStats() StatsInfo { + return StatsInfo{ + ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), + } +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index d892b120..42949e8b 100644 --- a/model/option.go +++ b/model/option.go @@ -122,6 +122,9 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ApiInfo"] = "" + common.OptionMap["UptimeKumaUrl"] = "" + common.OptionMap["UptimeKumaSlug"] = "" // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() diff --git a/model/token_cache.go b/model/token_cache.go index 0fe02fea..b2e0c951 100644 --- a/model/token_cache.go +++ b/model/token_cache.go @@ -19,7 +19,7 @@ func cacheSetToken(token Token) error { func cacheDeleteToken(key string) error { key = common.GenerateHMAC(key) - err := common.RedisHDelObj(fmt.Sprintf("token:%s", key)) + err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) if err != nil { return err } diff --git a/model/user_cache.go b/model/user_cache.go index bc412e77..d74877bd 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -3,11 +3,12 @@ package model import ( "encoding/json" "fmt" - "github.com/gin-gonic/gin" "one-api/common" "one-api/constant" "time" + "github.com/gin-gonic/gin" + "github.com/bytedance/gopkg/util/gopool" ) @@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error { if !common.RedisEnabled { return nil } - return common.RedisHDelObj(getUserCacheKey(userId)) + return common.RedisDelKey(getUserCacheKey(userId)) } // updateUserCache updates all user cache fields using hash diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 31e926d6..f30d4dc4 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { switch info.RelayMode { case constant.RelayModeEmbeddings: fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl) case constant.RelayModeImagesGenerations: fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) case constant.RelayModeCompletions: @@ -76,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf } func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { - return nil, errors.New("not implemented") + return ConvertRerankRequest(request), nil } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { @@ -103,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom err, usage = aliImageHandler(c, resp, info) case constant.RelayModeEmbeddings: err, usage = aliEmbeddingHandler(c, resp) + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) default: if info.IsStream { err, usage = openai.OaiStreamHandler(c, resp, info) diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go index 46de5e40..df64439b 100644 --- a/relay/channel/ali/constants.go +++ b/relay/channel/ali/constants.go @@ -8,6 +8,7 @@ var ModelList = []string{ "qwq-32b", "qwen3-235b-a22b", "text-embedding-v1", + "gte-rerank-v2", } var ChannelName = "ali" diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index f51286ad..dbd18968 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -1,5 +1,7 @@ package ali +import "one-api/dto" + type AliMessage struct { Content string `json:"content"` Role string `json:"role"` @@ -97,3 +99,28 @@ type AliImageRequest struct { } `json:"parameters,omitempty"` ResponseFormat string `json:"response_format,omitempty"` } + +type AliRerankParameters struct { + TopN *int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` +} + +type AliRerankInput struct { + Query string `json:"query"` + Documents []any `json:"documents"` +} + +type AliRerankRequest struct { + Model string `json:"model"` + Input AliRerankInput `json:"input"` + Parameters AliRerankParameters `json:"parameters,omitempty"` +} + +type AliRerankResponse struct { + Output struct { + Results []dto.RerankResponseResult `json:"results"` + } `json:"output"` + Usage AliUsage `json:"usage"` + RequestId string `json:"request_id"` + AliError +} diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go new file mode 100644 index 00000000..c9ae066a --- /dev/null +++ b/relay/channel/ali/rerank.go @@ -0,0 +1,83 @@ +package ali + +import ( + "encoding/json" + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/gin-gonic/gin" +) + +func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest { + returnDocuments := request.ReturnDocuments + if returnDocuments == nil { + t := true + returnDocuments = &t + } + return &AliRerankRequest{ + Model: request.Model, + Input: AliRerankInput{ + Query: request.Query, + Documents: request.Documents, + }, + Parameters: AliRerankParameters{ + TopN: &request.TopN, + ReturnDocuments: returnDocuments, + }, + } +} + +func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil + } + err = resp.Body.Close() + if err != nil { + return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil + } + + var aliResponse AliRerankResponse + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil + } + + if aliResponse.Code != "" { + return &dto.OpenAIErrorWithStatusCode{ + Error: dto.OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, + StatusCode: resp.StatusCode, + }, nil + } + + usage := dto.Usage{ + PromptTokens: aliResponse.Usage.TotalTokens, + CompletionTokens: 0, + TotalTokens: aliResponse.Usage.TotalTokens, + } + rerankResponse := dto.RerankResponse{ + Results: aliResponse.Output.Results, + Usage: usage, + } + + jsonResponse, err := json.Marshal(rerankResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil + } + + return nil, &usage +} diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go index 3fe893b3..2f1387c5 100644 --- a/relay/channel/ali/text.go +++ b/relay/channel/ali/text.go @@ -3,7 +3,6 @@ package ali import ( "bufio" "encoding/json" - "github.com/gin-gonic/gin" "io" "net/http" "one-api/common" @@ -11,6 +10,8 @@ import ( "one-api/relay/helper" "one-api/service" "strings" + + "github.com/gin-gonic/gin" ) // https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r @@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque } func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest { - if request.Model == "" { - request.Model = "text-embedding-v1" - } return &AliEmbeddingRequest{ Model: request.Model, Input: struct { @@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW }, nil } - fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse) + model := c.GetString("model") + if model == "" { + model = "text-embedding-v4" + } + fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model) jsonResponse, err := json.Marshal(fullTextResponse) if err != nil { return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil @@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW return nil, &fullTextResponse.Usage } -func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse { +func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse { openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ Object: "list", Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)), - Model: "text-embedding-v1", + Model: model, Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens}, } @@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe } func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Output.Text) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Output.Text, }, FinishReason: response.Output.FinishReason, } diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 1d733bd4..c3da5134 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc gopool.Go(func() { 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.") } @@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc } ticker := time.NewTicker(pingInterval) - // 退出时清理 ticker - defer ticker.Stop() + // 确保在任何情况下都清理ticker + defer func() { + ticker.Stop() + if common2.DebugEnabled { + println("SSE ping ticker stopped") + } + }() var pingMutex sync.Mutex if common2.DebugEnabled { println("SSE ping goroutine started") } + // 增加超时控制,防止goroutine长时间运行 + maxPingDuration := 120 * time.Minute // 最大ping持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + for { select { // 发送 ping 数据 case <-ticker.C: if err := sendPingData(c, &pingMutex); err != nil { + if common2.DebugEnabled { + println("SSE ping error, stopping goroutine:", err.Error()) + } return } // 收到退出信号 @@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc // request 结束 case <-c.Request.Context().Done(): return + // 超时保护,防止goroutine无限运行 + case <-pingTimeout.C: + if common2.DebugEnabled { + println("SSE ping goroutine timeout, stopping") + } + return } } }) @@ -148,19 +173,34 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc } func sendPingData(c *gin.Context, mutex *sync.Mutex) error { - mutex.Lock() - defer mutex.Unlock() + // 增加超时控制,防止锁死等待 + done := make(chan error, 1) + go func() { + mutex.Lock() + defer mutex.Unlock() - err := helper.PingData(c) - if err != nil { - common2.LogError(c, "SSE ping error: "+err.Error()) + err := helper.PingData(c) + if err != nil { + common2.LogError(c, "SSE ping error: "+err.Error()) + done <- err + return + } + + if common2.DebugEnabled { + println("SSE ping data sent.") + } + done <- nil + }() + + // 设置发送ping数据的超时时间 + select { + case err := <-done: return err + case <-time.After(10 * time.Second): + return errors.New("SSE ping data send timeout") + case <-c.Request.Context().Done(): + return errors.New("request context cancelled during ping") } - - if common2.DebugEnabled { - println("SSE ping data sent.") - } - return nil } func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { @@ -175,15 +215,23 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http client = service.GetHttpClient() } + var stopPinger context.CancelFunc if info.IsStream { helper.SetEventStreamHeaders(c) - // 处理流式请求的 ping 保活 generalSettings := operation_setting.GetGeneralSetting() if generalSettings.PingIntervalEnabled { pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second - stopPinger := startPingKeepAlive(c, pingInterval) - defer stopPinger() + stopPinger = startPingKeepAlive(c, pingInterval) + // 使用defer确保在任何情况下都能停止ping goroutine + defer func() { + if stopPinger != nil { + stopPinger() + if common2.DebugEnabled { + println("SSE ping goroutine stopped by defer") + } + } + }() } } diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index 62b06413..55b6c137 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { } func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { - content, _ := json.Marshal(response.Result) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Result, }, FinishReason: "stop", } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 95e7c4be..cb2c75b1 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla prompt := "" for _, message := range textRequest.Messages { if message.Role == "user" { - prompt += fmt.Sprintf("\n\nHuman: %s", message.Content) + prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent()) } else if message.Role == "assistant" { - prompt += fmt.Sprintf("\n\nAssistant: %s", message.Content) + prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent()) } else if message.Role == "system" { if prompt == "" { prompt = message.StringContent() @@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla } if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.IsStringContent() && message.IsStringContent() { - content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) - fmtMessage.Content = content + fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) // delete last message formatMessages = formatMessages[:len(formatMessages)-1] } } if fmtMessage.Content == nil { - content, _ := json.Marshal("...") - fmtMessage.Content = content + fmtMessage.SetStringContent("...") } formatMessages = append(formatMessages, fmtMessage) lastMessage = fmtMessage @@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto thinkingContent := "" if reqMode == RequestModeCompletion { - content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " ")) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: strings.TrimPrefix(claudeResponse.Completion, " "), Name: nil, }, FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go index 17b58dbc..10c4328b 100644 --- a/relay/channel/cohere/relay-cohere.go +++ b/relay/channel/cohere/relay-cohere.go @@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt openaiResp.Model = modelName openaiResp.Usage = usage - content, _ := json.Marshal(cohereResp.Text) openaiResp.Choices = []dto.OpenAITextResponseChoice{ { Index: 0, - Message: dto.Message{Content: content, Role: "assistant"}, + Message: dto.Message{Content: cohereResp.Text, Role: "assistant"}, FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), }, } diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go index 4e9afa23..d5dc9a81 100644 --- a/relay/channel/coze/dto.go +++ b/relay/channel/coze/dto.go @@ -10,7 +10,7 @@ type CozeError struct { type CozeEnterMessage struct { Role string `json:"role"` Type string `json:"type,omitempty"` - Content json.RawMessage `json:"content,omitempty"` + Content any `json:"content,omitempty"` MetaData json.RawMessage `json:"meta_data,omitempty"` ContentType string `json:"content_type,omitempty"` } diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go index b58fbe53..93e3e8d6 100644 --- a/relay/channel/dify/relay-dify.go +++ b/relay/channel/dify/relay-dify.go @@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf Created: common.GetTimestamp(), Usage: difyResponse.MetaData.Usage, } - content, _ := json.Marshal(difyResponse.Answer) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: difyResponse.Answer, }, FinishReason: "stop", } diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go index a0e38cb4..fa9108df 100644 --- a/relay/channel/gemini/dto.go +++ b/relay/channel/gemini/dto.go @@ -1,5 +1,7 @@ package gemini +import "encoding/json" + type GeminiChatRequest struct { Contents []GeminiChatContent `json:"contents"` SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` @@ -22,19 +24,38 @@ type GeminiInlineData struct { Data string `json:"data"` } +// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType +func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { + type Alias GeminiInlineData // Use type alias to avoid recursion + var aux struct { + Alias + MimeTypeSnake string `json:"mime_type"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + *g = GeminiInlineData(aux.Alias) // Copy other fields if any in future + + // Prioritize snake_case if present + if aux.MimeTypeSnake != "" { + g.MimeType = aux.MimeTypeSnake + } else if aux.MimeType != "" { // Fallback to camelCase from Alias + g.MimeType = aux.MimeType + } + // g.Data would be populated by aux.Alias.Data + return nil +} + type FunctionCall struct { FunctionName string `json:"name"` Arguments any `json:"args"` } -type GeminiFunctionResponseContent struct { - Name string `json:"name"` - Content any `json:"content"` -} - type FunctionResponse struct { - Name string `json:"name"` - Response GeminiFunctionResponseContent `json:"response"` + Name string `json:"name"` + Response map[string]interface{} `json:"response"` } type GeminiPartExecutableCode struct { @@ -63,6 +84,33 @@ type GeminiPart struct { CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` } +// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData +func (p *GeminiPart) UnmarshalJSON(data []byte) error { + // Alias to avoid recursion during unmarshalling + type Alias GeminiPart + var aux struct { + Alias + InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Assign fields from alias + *p = GeminiPart(aux.Alias) + + // Prioritize snake_case for InlineData if present + if aux.InlineDataSnake != nil { + p.InlineData = aux.InlineDataSnake + } else if aux.InlineData != nil { // Fallback to camelCase from Alias + p.InlineData = aux.InlineData + } + // Other fields like Text, FunctionCall etc. are already populated via aux.Alias + + return nil +} + type GeminiChatContent struct { Role string `json:"role,omitempty"` Parts []GeminiPart `json:"parts"` @@ -117,10 +165,16 @@ type GeminiChatResponse struct { } type GeminiUsageMetadata struct { - PromptTokenCount int `json:"promptTokenCount"` - CandidatesTokenCount int `json:"candidatesTokenCount"` - TotalTokenCount int `json:"totalTokenCount"` - ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` +} + +type GeminiPromptTokensDetails struct { + Modality string `json:"modality"` + TokenCount int `json:"tokenCount"` } // Imagen related structs diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go index c055e299..d9d0054d 100644 --- a/relay/channel/gemini/relay-gemini-native.go +++ b/relay/channel/gemini/relay-gemini-native.go @@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, } + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + // 直接返回 Gemini 原生格式的 JSON 响应 jsonResponse, err := json.Marshal(geminiResponse) if err != nil { @@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } } // 直接发送 GeminiChatResponse 响应 @@ -118,11 +136,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info } // 计算最终使用量 - usage.PromptTokensDetails.TextTokens = usage.PromptTokens usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens - // 结束流式响应 - helper.Done(c) + // 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为 + //helper.Done(c) return usage, nil } diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index bf1ece57..e2288faf 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { - if strings.HasSuffix(info.OriginModelName, "-thinking") { - // 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget - if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - IncludeThoughts: true, - } - } else { - budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) - if budgetTokens == 0 || budgetTokens > 24576 { - budgetTokens = 24576 - } - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(int(budgetTokens)), - IncludeThoughts: true, - } - } + if strings.HasSuffix(info.OriginModelName, "-thinking") { + // 硬编码不支持 ThinkingBudget 的旧模型 + unsupportedModels := []string{ + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-pro-preview-03-25", + } + + isUnsupported := false + for _, unsupportedModel := range unsupportedModels { + if strings.HasPrefix(info.OriginModelName, unsupportedModel) { + isUnsupported = true + break + } + } + + if isUnsupported { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + IncludeThoughts: true, + } + } else { + budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) + + // 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围) + isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25") + + if isNew25Pro { + // 新的2.5pro模型:ThinkingBudget范围为128-32768 + if budgetTokens == 0 || budgetTokens < 128 { + budgetTokens = 128 + } else if budgetTokens > 32768 { + budgetTokens = 32768 + } + } else { + // 其他模型:ThinkingBudget范围为0-24576 + if budgetTokens == 0 || budgetTokens > 24576 { + budgetTokens = 24576 + } + } + + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(int(budgetTokens)), + IncludeThoughts: true, + } + } } else if strings.HasSuffix(info.OriginModelName, "-nothinking") { - geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ - ThinkingBudget: common.GetPointer(0), + // 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128) + isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25") + + if !isNew25Pro { + // 只有非新2.5pro模型才支持-nothinking + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(0), + } } } } @@ -137,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) // json_data, _ := json.Marshal(geminiRequest.Tools) // common.SysLog("tools_json: " + string(json_data)) - } else if textRequest.Functions != nil { - //geminiRequest.Tools = []GeminiChatTool{ - // { - // FunctionDeclarations: textRequest.Functions, - // }, - //} } if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { @@ -173,17 +205,27 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon } else if val, exists := tool_call_ids[message.ToolCallId]; exists { name = val } - content := common.StrToMap(message.StringContent()) + var contentMap map[string]interface{} + contentStr := message.StringContent() + + // 1. 尝试解析为 JSON 对象 + if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil { + // 2. 如果失败,尝试解析为 JSON 数组 + var contentSlice []interface{} + if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil { + // 如果是数组,包装成对象 + contentMap = map[string]interface{}{"result": contentSlice} + } else { + // 3. 如果再次失败,作为纯文本处理 + contentMap = map[string]interface{}{"content": contentStr} + } + } + functionResp := &FunctionResponse{ - Name: name, - Response: GeminiFunctionResponseContent{ - Name: name, - Content: content, - }, - } - if content == nil { - functionResp.Response.Content = message.StringContent() + Name: name, + Response: contentMap, } + *parts = append(*parts, GeminiPart{ FunctionResponse: functionResp, }) @@ -280,13 +322,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon if part.GetInputAudio().Data == "" { return nil, fmt.Errorf("only base64 audio is supported in gemini") } - format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data) + base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data) if err != nil { return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) } parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ - MimeType: format, + MimeType: "audio/" + part.GetInputAudio().Format, Data: base64String, }, }) @@ -576,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp Created: common.GetTimestamp(), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } - content, _ := json.Marshal("") isToolCall := false for _, candidate := range response.Candidates { choice := dto.OpenAITextResponseChoice{ Index: int(candidate.Index), Message: dto.Message{ Role: "assistant", - Content: content, + Content: "", }, FinishReason: constant.FinishReasonStop, } @@ -738,6 +779,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } } err = helper.ObjectData(c, response) if err != nil { @@ -812,6 +860,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + fullTextResponse.Usage = usage jsonResponse, err := json.Marshal(fullTextResponse) if err != nil { diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go index 75272e34..e26c6101 100644 --- a/relay/channel/mistral/text.go +++ b/relay/channel/mistral/text.go @@ -1,13 +1,55 @@ package mistral import ( + "one-api/common" "one-api/dto" + "regexp" ) +var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$") + func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { messages := make([]dto.Message, 0, len(request.Messages)) + idMap := make(map[string]string) for _, message := range request.Messages { + // 1. tool_calls.id + toolCalls := message.ParseToolCalls() + if toolCalls != nil { + for i := range toolCalls { + if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) { + if newId, ok := idMap[toolCalls[i].ID]; ok { + toolCalls[i].ID = newId + } else { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[toolCalls[i].ID] = newId + toolCalls[i].ID = newId + } + } + } + } + message.SetToolCalls(toolCalls) + } + + // 2. tool_call_id + if message.ToolCallId != "" { + if newId, ok := idMap[message.ToolCallId]; ok { + message.ToolCallId = newId + } else { + if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[message.ToolCallId] = newId + message.ToolCallId = newId + } + } + } + } + mediaMessages := message.ParseContent() + if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" { + mediaMessages = []dto.MediaContent{} + } for j, mediaMessage := range mediaMessages { if mediaMessage.Type == dto.ContentTypeImageURL { imageUrl := mediaMessage.GetImageMedia() diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go index c8e337de..5c398b5e 100644 --- a/relay/channel/palm/relay-palm.go +++ b/relay/channel/palm/relay-palm.go @@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), } for i, candidate := range response.Candidates { - content, _ := json.Marshal(candidate.Content) choice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: "assistant", - Content: content, + Content: candidate.Content, }, FinishReason: "stop", } diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go index 5630650f..1446e06e 100644 --- a/relay/channel/tencent/relay-tencent.go +++ b/relay/channel/tencent/relay-tencent.go @@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon }, } if len(response.Choices) > 0 { - content, _ := json.Marshal(response.Choices[0].Messages.Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Choices[0].Messages.Content, }, FinishReason: response.Choices[0].FinishReason, } diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go index 15d33510..c6ef722c 100644 --- a/relay/channel/xunfei/relay-xunfei.go +++ b/relay/channel/xunfei/relay-xunfei.go @@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse }, } } - content, _ := json.Marshal(response.Payload.Choices.Text[0].Content) choice := dto.OpenAITextResponseChoice{ Index: 0, Message: dto.Message{ Role: "assistant", - Content: content, + Content: response.Payload.Choices.Text[0].Content, }, FinishReason: constant.FinishReasonStop, } diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go index b0cac858..744538e3 100644 --- a/relay/channel/zhipu/relay-zhipu.go +++ b/relay/channel/zhipu/relay-zhipu.go @@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { Usage: response.Data.Usage, } for i, choice := range response.Data.Choices { - content, _ := json.Marshal(strings.Trim(choice.Content, "\"")) openaiChoice := dto.OpenAITextResponseChoice{ Index: i, Message: dto.Message{ Role: choice.Role, - Content: content, + Content: strings.Trim(choice.Content, "\""), }, FinishReason: "", } diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index c1bc0d6e..a69877e2 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -3,6 +3,7 @@ package helper import ( "bufio" "context" + "fmt" "io" "net/http" "one-api/common" @@ -19,8 +20,8 @@ import ( ) const ( - InitialScannerBufferSize = 1 << 20 // 1MB (1*1024*1024) - MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) + InitialScannerBufferSize = 64 << 10 // 64KB (64*1024) + MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) DefaultPingInterval = 10 * time.Second ) @@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon return } - defer resp.Body.Close() + // 确保响应体总是被关闭 + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second if strings.HasPrefix(info.UpstreamModelName, "o") { @@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon } var ( - stopChan = make(chan bool, 2) + stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 scanner = bufio.NewScanner(resp.Body) ticker = time.NewTicker(streamingTimeout) pingTicker *time.Ticker writeMutex sync.Mutex // Mutex to protect concurrent writes + wg sync.WaitGroup // 用于等待所有 goroutine 退出 ) generalSettings := operation_setting.GetGeneralSetting() @@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon pingTicker = time.NewTicker(pingInterval) } + // 改进资源清理,确保所有 goroutine 正确退出 defer func() { + // 通知所有 goroutine 停止 + common.SafeSendBool(stopChan, true) + ticker.Stop() if pingTicker != nil { pingTicker.Stop() } + + // 等待所有 goroutine 退出,最多等待5秒 + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + common.LogError(c, "timeout waiting for goroutines to exit") + } + close(stopChan) }() + scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize) scanner.Split(bufio.ScanLines) SetEventStreamHeaders(c) @@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ctx = context.WithValue(ctx, "stop_chan", stopChan) - // Handle ping data sending + // Handle ping data sending with improved error handling if pingEnabled && pingTicker != nil { + wg.Add(1) gopool.Go(func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) + common.SafeSendBool(stopChan, true) + } + if common.DebugEnabled { + println("ping goroutine exited") + } + }() + + // 添加超时保护,防止 goroutine 无限运行 + maxPingDuration := 30 * time.Minute // 最大 ping 持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + for { select { case <-pingTicker.C: - writeMutex.Lock() // Lock before writing - err := PingData(c) - writeMutex.Unlock() // Unlock after writing - if err != nil { - common.LogError(c, "ping data error: "+err.Error()) - common.SafeSendBool(stopChan, true) + // 使用超时机制防止写操作阻塞 + done := make(chan error, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- PingData(c) + }() + + select { + case err := <-done: + if err != nil { + common.LogError(c, "ping data error: "+err.Error()) + return + } + if common.DebugEnabled { + println("ping data sent") + } + case <-time.After(10 * time.Second): + common.LogError(c, "ping data send timeout") + return + case <-ctx.Done(): + return + case <-stopChan: return } - if common.DebugEnabled { - println("ping data sent") - } case <-ctx.Done(): - if common.DebugEnabled { - println("ping data goroutine stopped") - } + return + case <-stopChan: + return + case <-c.Request.Context().Done(): + // 监听客户端断开连接 + return + case <-pingTimeout.C: + common.LogError(c, "ping goroutine max duration reached") return } } }) } + // Scanner goroutine with improved error handling + wg.Add(1) common.RelayCtxGo(ctx, func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) + } + common.SafeSendBool(stopChan, true) + if common.DebugEnabled { + println("scanner goroutine exited") + } + }() + for scanner.Scan() { + // 检查是否需要停止 + select { + case <-stopChan: + return + case <-ctx.Done(): + return + case <-c.Request.Context().Done(): + return + default: + } + ticker.Reset(streamingTimeout) data := scanner.Text() if common.DebugEnabled { @@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon data = strings.TrimSuffix(data, "\r") if !strings.HasPrefix(data, "[DONE]") { info.SetFirstResponseTime() - writeMutex.Lock() // Lock before writing - success := dataHandler(data) - writeMutex.Unlock() // Unlock after writing - if !success { - break + + // 使用超时机制防止写操作阻塞 + done := make(chan bool, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- dataHandler(data) + }() + + select { + case success := <-done: + if !success { + return + } + case <-time.After(10 * time.Second): + common.LogError(c, "data handler timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return } } } @@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon common.LogError(c, "scanner error: "+err.Error()) } } - - common.SafeSendBool(stopChan, true) }) + // 主循环等待完成或超时 select { case <-ticker.C: // 超时处理逻辑 common.LogError(c, "streaming timeout") - common.SafeSendBool(stopChan, true) case <-stopChan: // 正常结束 common.LogInfo(c, "streaming finished") + case <-c.Request.Context().Done(): + // 客户端断开连接 + common.LogInfo(c, "client disconnected") } } diff --git a/relay/relay-text.go b/relay/relay-text.go index f1105907..a48a664a 100644 --- a/relay/relay-text.go +++ b/relay/relay-text.go @@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, promptTokens := usage.PromptTokens cacheTokens := usage.PromptTokensDetails.CachedTokens imageTokens := usage.PromptTokensDetails.ImageTokens + audioTokens := usage.PromptTokensDetails.AudioTokens completionTokens := usage.CompletionTokens modelName := relayInfo.OriginModelName @@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, dPromptTokens := decimal.NewFromInt(int64(promptTokens)) dCacheTokens := decimal.NewFromInt(int64(cacheTokens)) dImageTokens := decimal.NewFromInt(int64(imageTokens)) + dAudioTokens := decimal.NewFromInt(int64(audioTokens)) dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) dCompletionRatio := decimal.NewFromFloat(completionRatio) dCacheRatio := decimal.NewFromFloat(cacheRatio) @@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) - extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s", + extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", fileSearchTool.CallCount, dFileSearchQuota.String()) } } var quotaCalculateDecimal decimal.Decimal - if !priceData.UsePrice { - nonCachedTokens := dPromptTokens.Sub(dCacheTokens) - cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio) - promptQuota := nonCachedTokens.Add(cachedTokensWithRatio) - if imageTokens > 0 { - nonImageTokens := dPromptTokens.Sub(dImageTokens) - imageTokensWithRatio := dImageTokens.Mul(dImageRatio) - promptQuota = nonImageTokens.Add(imageTokensWithRatio) + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !priceData.UsePrice { + baseTokens := dPromptTokens + // 减去 cached tokens + var cachedTokensWithRatio decimal.Decimal + if !dCacheTokens.IsZero() { + baseTokens = baseTokens.Sub(dCacheTokens) + cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) } + // 减去 image tokens + var imageTokensWithRatio decimal.Decimal + if !dImageTokens.IsZero() { + baseTokens = baseTokens.Sub(dImageTokens) + imageTokensWithRatio = dImageTokens.Mul(dImageRatio) + } + + // 减去 Gemini audio tokens + if !dAudioTokens.IsZero() { + audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName) + if audioInputPrice > 0 { + // 重新计算 base tokens + baseTokens = baseTokens.Sub(dAudioTokens) + audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) + } + } + promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio) + completionQuota := dCompletionTokens.Mul(dCompletionRatio) quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio) @@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, // 添加 responses tools call 调用的配额 quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) + // 添加 audio input 独立计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quota := int(quotaCalculateDecimal.Round(0).IntPart()) totalTokens := promptTokens + completionTokens @@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, other["file_search_price"] = fileSearchPrice } } + if !audioInputQuota.IsZero() { + other["audio_input_seperate_price"] = true + other["audio_input_token_count"] = audioTokens + other["audio_input_price"] = audioInputPrice + } model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel, tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) } diff --git a/router/api-router.go b/router/api-router.go index 1720ff57..0ab8be7f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) @@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", controller.FetchModels) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) + channelRoute.GET("/tag/models", controller.GetTagModels) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/router/relay-router.go b/router/relay-router.go index 1115a491..aa7f27a8 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -11,6 +11,7 @@ import ( func SetRelayRouter(router *gin.Engine) { router.Use(middleware.CORS()) router.Use(middleware.DecompressRequestMiddleware()) + router.Use(middleware.StatsMiddleware()) // https://platform.openai.com/docs/api-reference/introduction modelsRouter := router.Group("/v1/models") modelsRouter.Use(middleware.TokenAuth()) diff --git a/service/audio.go b/service/audio.go index d558e96f..c4b6f01b 100644 --- a/service/audio.go +++ b/service/audio.go @@ -3,6 +3,7 @@ package service import ( "encoding/base64" "fmt" + "strings" ) func parseAudio(audioBase64 string, format string) (duration float64, err error) { @@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error) duration = float64(samplesCount) / float64(sampleRate) return duration, nil } + +func DecodeBase64AudioData(audioBase64 string) (string, error) { + // 检查并移除 data:audio/xxx;base64, 前缀 + idx := strings.Index(audioBase64, ",") + if idx != -1 { + audioBase64 = audioBase64[idx+1:] + } + + // 解码 Base64 数据 + _, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return "", fmt.Errorf("base64 decode error: %v", err) + } + + return audioBase64, nil +} diff --git a/service/token_counter.go b/service/token_counter.go index d63b54ad..82de0a05 100644 --- a/service/token_counter.go +++ b/service/token_counter.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/tiktoken-go/tokenizer" + "github.com/tiktoken-go/tokenizer/codec" "image" "log" "math" @@ -11,78 +13,63 @@ import ( "one-api/constant" "one-api/dto" relaycommon "one-api/relay/common" - "one-api/setting/operation_setting" "strings" + "sync" "unicode/utf8" - - "github.com/pkoukk/tiktoken-go" ) // tokenEncoderMap won't grow after initialization -var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} -var defaultTokenEncoder *tiktoken.Tiktoken -var o200kTokenEncoder *tiktoken.Tiktoken +var defaultTokenEncoder tokenizer.Codec + +// tokenEncoderMap is used to store token encoders for different models +var tokenEncoderMap = make(map[string]tokenizer.Codec) + +// tokenEncoderMutex protects tokenEncoderMap for concurrent access +var tokenEncoderMutex sync.RWMutex func InitTokenEncoders() { common.SysLog("initializing token encoders") - cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE) - if err != nil { - common.FatalLog(fmt.Sprintf("failed to get gpt-3.5-turbo token encoder: %s", err.Error())) - } - defaultTokenEncoder = cl100TokenEncoder - o200kTokenEncoder, err = tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE) - if err != nil { - common.FatalLog(fmt.Sprintf("failed to get gpt-4o token encoder: %s", err.Error())) - } - for model, _ := range operation_setting.GetDefaultModelRatioMap() { - if strings.HasPrefix(model, "gpt-3.5") { - tokenEncoderMap[model] = cl100TokenEncoder - } else if strings.HasPrefix(model, "gpt-4") { - if strings.HasPrefix(model, "gpt-4o") { - tokenEncoderMap[model] = o200kTokenEncoder - } else { - tokenEncoderMap[model] = defaultTokenEncoder - } - } else if strings.HasPrefix(model, "o") { - tokenEncoderMap[model] = o200kTokenEncoder - } else { - tokenEncoderMap[model] = defaultTokenEncoder - } - } + defaultTokenEncoder = codec.NewCl100kBase() common.SysLog("token encoders initialized") } -func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken { - if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") { - return o200kTokenEncoder +func getTokenEncoder(model string) tokenizer.Codec { + // First, try to get the encoder from cache with read lock + tokenEncoderMutex.RLock() + if encoder, exists := tokenEncoderMap[model]; exists { + tokenEncoderMutex.RUnlock() + return encoder } - return defaultTokenEncoder + tokenEncoderMutex.RUnlock() + + // If not in cache, create new encoder with write lock + tokenEncoderMutex.Lock() + defer tokenEncoderMutex.Unlock() + + // Double-check if another goroutine already created the encoder + if encoder, exists := tokenEncoderMap[model]; exists { + return encoder + } + + // Create new encoder + modelCodec, err := tokenizer.ForModel(tokenizer.Model(model)) + if err != nil { + // Cache the default encoder for this model to avoid repeated failures + tokenEncoderMap[model] = defaultTokenEncoder + return defaultTokenEncoder + } + + // Cache the new encoder + tokenEncoderMap[model] = modelCodec + return modelCodec } -func getTokenEncoder(model string) *tiktoken.Tiktoken { - tokenEncoder, ok := tokenEncoderMap[model] - if ok && tokenEncoder != nil { - return tokenEncoder - } - // 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型 - if ok { - tokenEncoder, err := tiktoken.EncodingForModel(model) - if err != nil { - common.SysError(fmt.Sprintf("failed to get token encoder for model %s: %s, using encoder for gpt-3.5-turbo", model, err.Error())) - tokenEncoder = getModelDefaultTokenEncoder(model) - } - tokenEncoderMap[model] = tokenEncoder - return tokenEncoder - } - // 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder - return getModelDefaultTokenEncoder(model) -} - -func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { +func getTokenNum(tokenEncoder tokenizer.Codec, text string) int { if text == "" { return 0 } - return len(tokenEncoder.Encode(text, nil, nil)) + tkm, _ := tokenEncoder.Count(text) + return tkm } func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) { @@ -261,12 +248,16 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream //} tokenNum += 1000 case "tool_use": - tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) - inputJSON, _ := json.Marshal(mediaMessage.Input) - tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + if mediaMessage.Input != nil { + tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) + inputJSON, _ := json.Marshal(mediaMessage.Input) + tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + } case "tool_result": - contentJSON, _ := json.Marshal(mediaMessage.Content) - tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + if mediaMessage.Content != nil { + contentJSON, _ := json.Marshal(mediaMessage.Content) + tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + } } } } @@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod for _, message := range messages { tokenNum += tokensPerMessage tokenNum += getTokenNum(tokenEncoder, message.Role) - if len(message.Content) > 0 { + if message.Content != nil { if message.Name != nil { tokenNum += tokensPerName tokenNum += getTokenNum(tokenEncoder, *message.Name) diff --git a/setting/console.go b/setting/console.go new file mode 100644 index 00000000..94023666 --- /dev/null +++ b/setting/console.go @@ -0,0 +1,327 @@ +package setting + +import ( + "encoding/json" + "fmt" + "net/url" + "one-api/common" + "regexp" + "sort" + "strings" + "time" +) + +// ValidateConsoleSettings 验证控制台设置信息格式 +func ValidateConsoleSettings(settingsStr string, settingType string) error { + if settingsStr == "" { + return nil // 空字符串是合法的 + } + + switch settingType { + case "ApiInfo": + return validateApiInfo(settingsStr) + case "Announcements": + return validateAnnouncements(settingsStr) + case "FAQ": + return validateFAQ(settingsStr) + default: + return fmt.Errorf("未知的设置类型:%s", settingType) + } +} + +// validateApiInfo 验证API信息格式 +func validateApiInfo(apiInfoStr string) error { + var apiInfoList []map[string]interface{} + if err := json.Unmarshal([]byte(apiInfoStr), &apiInfoList); err != nil { + return fmt.Errorf("API信息格式错误:%s", err.Error()) + } + + // 验证数组长度 + if len(apiInfoList) > 50 { + return fmt.Errorf("API信息数量不能超过50个") + } + + // 允许的颜色值 + validColors := map[string]bool{ + "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, + } + + // URL正则表达式,支持域名和IP地址格式 + // 域名格式:https://example.com 或 https://sub.example.com:8080 + // IP地址格式:https://192.168.1.1 或 https://192.168.1.1:8080 + urlRegex := regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?::[0-9]{1,5})?(?:/.*)?$`) + + for i, apiInfo := range apiInfoList { + // 检查必填字段 + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } + + // 验证URL格式 + if !urlRegex.MatchString(urlStr) { + return fmt.Errorf("第%d个API信息的URL格式不正确", i+1) + } + + // 验证URL可解析性 + if _, err := url.Parse(urlStr); err != nil { + return fmt.Errorf("第%d个API信息的URL无法解析:%s", i+1, err.Error()) + } + + // 验证字段长度 + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } + + // 验证颜色值 + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } + + // 检查并过滤危险字符(防止XSS) + dangerousChars := []string{" 100 { + return fmt.Errorf("系统公告数量不能超过100个") + } + + // 允许的类型值 + validTypes := map[string]bool{ + "default": true, "ongoing": true, "success": true, "warning": true, "error": true, + } + + for i, announcement := range announcementsList { + // 检查必填字段 + content, ok := announcement["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个公告缺少内容字段", i+1) + } + + // 检查发布日期字段 + publishDate, exists := announcement["publishDate"] + if !exists { + return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) + } + + publishDateStr, ok := publishDate.(string) + if !ok || publishDateStr == "" { + return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) + } + + // 验证ISO日期格式 + if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { + return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) + } + + // 验证可选字段 + if announcementType, exists := announcement["type"]; exists { + if typeStr, ok := announcementType.(string); ok { + if !validTypes[typeStr] { + return fmt.Errorf("第%d个公告的类型值不合法", i+1) + } + } + } + + // 验证字段长度 + if len(content) > 500 { + return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) + } + + if extra, exists := announcement["extra"]; exists { + if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { + return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + } + } + + // 检查并过滤危险字符(防止XSS) + dangerousChars := []string{" 100 { + return fmt.Errorf("常见问答数量不能超过100个") + } + + for i, faq := range faqList { + // 检查必填字段 + title, ok := faq["title"].(string) + if !ok || title == "" { + return fmt.Errorf("第%d个问答缺少标题字段", i+1) + } + + content, ok := faq["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个问答缺少内容字段", i+1) + } + + // 验证字段长度 + if len(title) > 200 { + return fmt.Errorf("第%d个问答的标题长度不能超过200字符", i+1) + } + + if len(content) > 1000 { + return fmt.Errorf("第%d个问答的内容长度不能超过1000字符", i+1) + } + + // 检查并过滤危险字符(防止XSS) + dangerousChars := []string{" 20 { + announcements = announcements[:20] + } + + return announcements +} + +// GetFAQ 获取常见问答列表 +func GetFAQ() []map[string]interface{} { + common.OptionMapRWMutex.RLock() + faqStr, exists := common.OptionMap["FAQ"] + common.OptionMapRWMutex.RUnlock() + + if !exists || faqStr == "" { + return []map[string]interface{}{} + } + + var faq []map[string]interface{} + if err := json.Unmarshal([]byte(faqStr), &faq); err != nil { + return []map[string]interface{}{} + } + + return faq +} \ No newline at end of file diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 974c4ed2..3e1af99e 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -14,6 +14,13 @@ const ( FileSearchPrice = 2.5 ) +const ( + // Gemini Audio Input Price + Gemini25FlashPreviewInputAudioPrice = 1.00 + Gemini25FlashNativeAudioInputAudioPrice = 3.00 + Gemini20FlashInputAudioPrice = 0.70 +) + func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { // 确定模型类型 // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 @@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 func GetFileSearchPricePerThousand() float64 { return FileSearchPrice } + +func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { + return Gemini25FlashPreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { + return Gemini25FlashNativeAudioInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { + return Gemini20FlashInputAudioPrice + } + return 0 +} diff --git a/web/bun.lockb b/web/bun.lockb index c71070a6..4d0cdea9 100755 Binary files a/web/bun.lockb and b/web/bun.lockb differ diff --git a/web/package.json b/web/package.json index e6ce588d..df98db8e 100644 --- a/web/package.json +++ b/web/package.json @@ -6,27 +6,42 @@ "dependencies": { "@douyinfe/semi-icons": "^2.63.1", "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", "axios": "^0.27.2", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", "dayjs": "^1.11.11", "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", "marked": "^4.1.1", + "mermaid": "^11.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.3.0", "react-telegram-login": "^1.1.2", "react-toastify": "^9.0.8", "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "semantic-ui-offline": "^2.5.0", "semantic-ui-react": "^2.1.3", - "sse": "https://github.com/mpetazzoni/sse.js", - "i18next": "^23.16.8", - "react-i18next": "^13.0.0", - "i18next-browser-languagedetector": "^7.2.0" + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4" }, "scripts": { "dev": "vite", @@ -54,9 +69,13 @@ ] }, "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", "@so1ve/prettier-config": "^3.1.0", "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", "prettier": "^3.0.0", + "tailwindcss": "^3", "typescript": "4.4.2", "vite": "^5.2.0" }, diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 0c10d1e5..ab5f17bc 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/logo.png b/web/public/logo.png index 8aea273d..851556f6 100644 Binary files a/web/public/logo.png and b/web/public/logo.png differ diff --git a/web/src/App.js b/web/src/App.js index ed53f6a0..2d715767 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -1,15 +1,15 @@ -import React, { lazy, Suspense, useContext, useEffect } from 'react'; +import React, { lazy, Suspense } from 'react'; import { Route, Routes, useLocation } from 'react-router-dom'; -import Loading from './components/Loading'; +import Loading from './components/common/Loading.js'; import User from './pages/User'; -import { PrivateRoute } from './components/PrivateRoute'; -import RegisterForm from './components/RegisterForm'; -import LoginForm from './components/LoginForm'; +import { AuthRedirect, PrivateRoute } from './helpers'; +import RegisterForm from './components/auth/RegisterForm.js'; +import LoginForm from './components/auth/LoginForm.js'; import NotFound from './pages/NotFound'; import Setting from './pages/Setting'; import EditUser from './pages/User/EditUser'; -import PasswordResetForm from './components/PasswordResetForm'; -import PasswordResetConfirm from './components/PasswordResetConfirm'; +import PasswordResetForm from './components/auth/PasswordResetForm.js'; +import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js'; import Channel from './pages/Channel'; import Token from './pages/Token'; import EditChannel from './pages/Channel/EditChannel'; @@ -18,15 +18,14 @@ import TopUp from './pages/TopUp'; import Log from './pages/Log'; import Chat from './pages/Chat'; import Chat2Link from './pages/Chat2Link'; -import { Layout } from '@douyinfe/semi-ui'; import Midjourney from './pages/Midjourney'; import Pricing from './pages/Pricing/index.js'; import Task from './pages/Task/index.js'; -import Playground from './pages/Playground/Playground.js'; -import OAuth2Callback from './components/OAuth2Callback.js'; -import PersonalSetting from './components/PersonalSetting.js'; +import Playground from './pages/Playground/index.js'; +import OAuth2Callback from './components/auth/OAuth2Callback.js'; +import PersonalSetting from './components/settings/PersonalSetting.js'; import Setup from './pages/Setup/index.js'; -import SetupCheck from './components/SetupCheck'; +import SetupCheck from './components/layout/SetupCheck.js'; const Home = lazy(() => import('./pages/Home')); const Detail = lazy(() => import('./pages/Detail')); @@ -55,7 +54,7 @@ function App() { } /> @@ -63,7 +62,7 @@ function App() { } /> } key={location.pathname}> @@ -71,7 +70,7 @@ function App() { } /> } key={location.pathname}> @@ -79,7 +78,7 @@ function App() { } /> @@ -87,7 +86,7 @@ function App() { } /> @@ -95,7 +94,7 @@ function App() { } /> @@ -103,7 +102,7 @@ function App() { } /> @@ -111,7 +110,7 @@ function App() { } /> } key={location.pathname}> @@ -119,7 +118,7 @@ function App() { } /> } key={location.pathname}> @@ -138,7 +137,9 @@ function App() { path='/login' element={ } key={location.pathname}> - + + + } /> @@ -146,7 +147,9 @@ function App() { path='/register' element={ } key={location.pathname}> - + + + } /> @@ -183,7 +186,7 @@ function App() { } /> } key={location.pathname}> @@ -193,7 +196,7 @@ function App() { } /> } key={location.pathname}> @@ -203,7 +206,7 @@ function App() { } /> } key={location.pathname}> @@ -213,7 +216,7 @@ function App() { } /> @@ -221,7 +224,7 @@ function App() { } /> } key={location.pathname}> @@ -231,7 +234,7 @@ function App() { } /> } key={location.pathname}> @@ -241,7 +244,7 @@ function App() { } /> } key={location.pathname}> @@ -267,7 +270,7 @@ function App() { } /> } key={location.pathname}> diff --git a/web/src/components/Footer.js b/web/src/components/Footer.js deleted file mode 100644 index 7092b873..00000000 --- a/web/src/components/Footer.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useEffect, useState, useContext } from 'react'; -import { useTranslation } from 'react-i18next'; -import { getFooterHTML, getSystemName } from '../helpers'; -import { Layout, Tooltip } from '@douyinfe/semi-ui'; -import { StyleContext } from '../context/Style/index.js'; - -const FooterBar = () => { - const { t } = useTranslation(); - const systemName = getSystemName(); - const [footer, setFooter] = useState(getFooterHTML()); - const [styleState] = useContext(StyleContext); - let remainCheckTimes = 5; - - const loadFooter = () => { - let footer_html = localStorage.getItem('footer_html'); - if (footer_html) { - setFooter(footer_html); - } - }; - - const defaultFooter = ( -
- - New API {import.meta.env.VITE_REACT_APP_VERSION}{' '} - - {t('由')}{' '} - - Calcium-Ion - {' '} - {t('开发,基于')}{' '} - - One API - -
- ); - - useEffect(() => { - const timer = setInterval(() => { - if (remainCheckTimes <= 0) { - clearInterval(timer); - return; - } - remainCheckTimes--; - loadFooter(); - }, 200); - return () => clearTimeout(timer); - }, []); - - return ( -
- {footer ? ( -
- ) : ( - defaultFooter - )} -
- ); -}; - -export default FooterBar; diff --git a/web/src/components/HeaderBar.js b/web/src/components/HeaderBar.js deleted file mode 100644 index de2401a1..00000000 --- a/web/src/components/HeaderBar.js +++ /dev/null @@ -1,494 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { useSetTheme, useTheme } from '../context/Theme'; -import { useTranslation } from 'react-i18next'; - -import { API, getLogo, getSystemName, isMobile, showSuccess } from '../helpers'; -import '../index.css'; - -import fireworks from 'react-fireworks'; - -import { - IconClose, - IconHelpCircle, - IconHome, - IconHomeStroked, - IconIndentLeft, - IconComment, - IconKey, - IconMenu, - IconNoteMoneyStroked, - IconPriceTag, - IconUser, - IconLanguage, - IconInfoCircle, - IconCreditCard, - IconTerminal, -} from '@douyinfe/semi-icons'; -import { - Avatar, - Button, - Dropdown, - Layout, - Nav, - Switch, - Tag, -} from '@douyinfe/semi-ui'; -import { stringToColor } from '../helpers/render'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import { StyleContext } from '../context/Style/index.js'; -import { StatusContext } from '../context/Status/index.js'; - -// 自定义顶部栏样式 -const headerStyle = { - boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)', - borderBottom: '1px solid var(--semi-color-border)', - background: 'var(--semi-color-bg-0)', - transition: 'all 0.3s ease', - width: '100%', -}; - -// 自定义顶部栏按钮样式 -const headerItemStyle = { - borderRadius: '4px', - margin: '0 4px', - transition: 'all 0.3s ease', -}; - -// 自定义顶部栏按钮悬停样式 -const headerItemHoverStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', -}; - -// 自定义顶部栏Logo样式 -const logoStyle = { - display: 'flex', - alignItems: 'center', - gap: '10px', - padding: '0 10px', - height: '100%', -}; - -// 自定义顶部栏系统名称样式 -const systemNameStyle = { - fontWeight: 'bold', - fontSize: '18px', - background: - 'linear-gradient(45deg, var(--semi-color-primary), var(--semi-color-secondary))', - WebkitBackgroundClip: 'text', - WebkitTextFillColor: 'transparent', - padding: '0 5px', -}; - -// 自定义顶部栏按钮图标样式 -const headerIconStyle = { - fontSize: '18px', - transition: 'all 0.3s ease', -}; - -// 自定义头像样式 -const avatarStyle = { - margin: '4px', - cursor: 'pointer', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - transition: 'all 0.3s ease', -}; - -// 自定义下拉菜单样式 -const dropdownStyle = { - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - overflow: 'hidden', -}; - -// 自定义主题切换开关样式 -const switchStyle = { - margin: '0 8px', -}; - -const HeaderBar = () => { - const { t, i18n } = useTranslation(); - const [userState, userDispatch] = useContext(UserContext); - const [styleState, styleDispatch] = useContext(StyleContext); - const [statusState, statusDispatch] = useContext(StatusContext); - let navigate = useNavigate(); - const [currentLang, setCurrentLang] = useState(i18n.language); - - const systemName = getSystemName(); - const logo = getLogo(); - const currentDate = new Date(); - // enable fireworks on new year(1.1 and 2.9-2.24) - const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1; - - // Check if self-use mode is enabled - const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false; - const docsLink = statusState?.status?.docs_link || ''; - const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; - - let buttons = [ - { - text: t('首页'), - itemKey: 'home', - to: '/', - icon: , - }, - { - text: t('控制台'), - itemKey: 'detail', - to: '/', - icon: , - }, - { - text: t('定价'), - itemKey: 'pricing', - to: '/pricing', - icon: , - }, - // Only include the docs button if docsLink exists - ...(docsLink - ? [ - { - text: t('文档'), - itemKey: 'docs', - isExternal: true, - externalLink: docsLink, - icon: , - }, - ] - : []), - { - text: t('关于'), - itemKey: 'about', - to: '/about', - icon: , - }, - ]; - - async function logout() { - await API.get('/api/user/logout'); - showSuccess(t('注销成功!')); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } - - const handleNewYearClick = () => { - fireworks.init('root', {}); - fireworks.start(); - setTimeout(() => { - fireworks.stop(); - setTimeout(() => { - window.location.reload(); - }, 10000); - }, 3000); - }; - - const theme = useTheme(); - const setTheme = useSetTheme(); - - useEffect(() => { - if (theme === 'dark') { - document.body.setAttribute('theme-mode', 'dark'); - } else { - document.body.removeAttribute('theme-mode'); - } - // 发送当前主题模式给子页面 - const iframe = document.querySelector('iframe'); - if (iframe) { - iframe.contentWindow.postMessage({ themeMode: theme }, '*'); - } - - if (isNewYear) { - console.log('Happy New Year!'); - } - }, [theme]); - - useEffect(() => { - const handleLanguageChanged = (lng) => { - setCurrentLang(lng); - const iframe = document.querySelector('iframe'); - if (iframe) { - iframe.contentWindow.postMessage({ lang: lng }, '*'); - } - }; - - i18n.on('languageChanged', handleLanguageChanged); - - return () => { - i18n.off('languageChanged', handleLanguageChanged); - }; - }, [i18n]); - - const handleLanguageChange = (lang) => { - i18n.changeLanguage(lang); - }; - - return ( - <> - -
- -
-
- - ); -}; - -export default HeaderBar; diff --git a/web/src/components/Loading.js b/web/src/components/Loading.js deleted file mode 100644 index 14242e44..00000000 --- a/web/src/components/Loading.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { Spin } from '@douyinfe/semi-ui'; - -const Loading = ({ prompt: name = 'page' }) => { - return ( - - 加载{name}中... - - ); -}; - -export default Loading; diff --git a/web/src/components/LoginForm.js b/web/src/components/LoginForm.js deleted file mode 100644 index 6721199f..00000000 --- a/web/src/components/LoginForm.js +++ /dev/null @@ -1,385 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, -} from '../helpers'; -import { - onGitHubOAuthClicked, - onOIDCClicked, - onLinuxDOOAuthClicked, -} from './utils'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Divider, - Form, - Icon, - Layout, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import TelegramLoginButton from 'react-telegram-login'; - -import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons'; -import OIDCIcon from './OIDCIcon.js'; -import WeChatIcon from './WeChatIcon'; -import { setUserData } from '../helpers/data.js'; -import LinuxDoIcon from './LinuxDoIcon.js'; -import { useTranslation } from 'react-i18next'; - -const LoginForm = () => { - const [inputs, setInputs] = useState({ - username: '', - password: '', - wechat_verification_code: '', - }); - const [searchParams, setSearchParams] = useSearchParams(); - const [submitted, setSubmitted] = useState(false); - const { username, password } = inputs; - const [userState, userDispatch] = useContext(UserContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - let navigate = useNavigate(); - const [status, setStatus] = useState({}); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const { t } = useTranslation(); - - const logo = getLogo(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - useEffect(() => { - if (searchParams.get('expired')) { - showError(t('未登录或登录已过期,请重新登录')); - } - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }, []); - - const onWeChatLoginClicked = () => { - setShowWeChatLoginModal(true); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setSubmitted(true); - if (username && password) { - const res = await API.post( - `/api/user/login?turnstile=${turnstileToken}`, - { - username, - password, - }, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - setUserData(data); - updateAPI(); - showSuccess('登录成功!'); - if (username === 'root' && password === '123456') { - Modal.error({ - title: '您正在使用默认密码!', - content: '请立刻修改默认密码!', - centered: true, - }); - } - navigate('/token'); - } else { - showError(message); - } - } else { - showError('请输入用户名和密码!'); - } - } - - // 添加Telegram登录处理函数 - const onTelegramLoginClicked = async (response) => { - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - }; - - return ( -
- - - -
-
- - - {t('用户登录')} - -
- handleChange('username', value)} - /> - handleChange('password', value)} - /> - - - -
- - {t('没有账户?')}{' '} - {t('点击注册')} - - - {t('忘记密码?')} {t('点击重置')} - -
- {status.github_oauth || - status.oidc_enabled || - status.wechat_login || - status.telegram_oauth || - status.linuxdo_oauth ? ( - <> - - {t('第三方登录')} - -
- {status.github_oauth ? ( -
- {status.telegram_oauth ? ( - <> -
- -
- - ) : ( - <> - )} - - ) : ( - <> - )} - setShowWeChatLoginModal(false)} - okText={t('登录')} - size={'small'} - centered={true} - > -
- -
-
-

- {t( - '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)', - )} -

-
-
- - handleChange('wechat_verification_code', value) - } - /> - -
-
- {turnstileEnabled ? ( -
- { - setTurnstileToken(token); - }} - /> -
- ) : ( - <> - )} -
-
-
-
-
- ); -}; - -export default LoginForm; diff --git a/web/src/components/MjLogsTable.js b/web/src/components/MjLogsTable.js deleted file mode 100644 index 502569cf..00000000 --- a/web/src/components/MjLogsTable.js +++ /dev/null @@ -1,660 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - isAdmin, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { - Banner, - Button, - Form, - ImagePreview, - Layout, - Modal, - Progress, - Table, - Tag, - Typography, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; -import { useTranslation } from 'react-i18next'; - -const colors = [ - 'amber', - 'blue', - 'cyan', - 'green', - 'grey', - 'indigo', - 'light-blue', - 'lime', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'violet', - 'yellow', -]; - -const LogsTable = () => { - const { t } = useTranslation(); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContent, setModalContent] = useState(''); - function renderType(type) { - switch (type) { - case 'IMAGINE': - return ( - - {t('绘图')} - - ); - case 'UPSCALE': - return ( - - {t('放大')} - - ); - case 'VARIATION': - return ( - - {t('变换')} - - ); - case 'HIGH_VARIATION': - return ( - - {t('强变换')} - - ); - case 'LOW_VARIATION': - return ( - - {t('弱变换')} - - ); - case 'PAN': - return ( - - {t('平移')} - - ); - case 'DESCRIBE': - return ( - - {t('图生文')} - - ); - case 'BLEND': - return ( - - {t('图混合')} - - ); - case 'UPLOAD': - return ( - - 上传文件 - - ); - case 'SHORTEN': - return ( - - {t('缩词')} - - ); - case 'REROLL': - return ( - - {t('重绘')} - - ); - case 'INPAINT': - return ( - - {t('局部重绘-提交')} - - ); - case 'ZOOM': - return ( - - {t('变焦')} - - ); - case 'CUSTOM_ZOOM': - return ( - - {t('自定义变焦-提交')} - - ); - case 'MODAL': - return ( - - {t('窗口处理')} - - ); - case 'SWAP_FACE': - return ( - - {t('换脸')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - function renderCode(code) { - switch (code) { - case 1: - return ( - - {t('已提交')} - - ); - case 21: - return ( - - {t('等待中')} - - ); - case 22: - return ( - - {t('重复提交')} - - ); - case 0: - return ( - - {t('未提交')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - function renderStatus(type) { - switch (type) { - case 'SUCCESS': - return ( - - {t('成功')} - - ); - case 'NOT_START': - return ( - - {t('未启动')} - - ); - case 'SUBMITTED': - return ( - - {t('队列中')} - - ); - case 'IN_PROGRESS': - return ( - - {t('执行中')} - - ); - case 'FAILURE': - return ( - - {t('失败')} - - ); - case 'MODAL': - return ( - - {t('窗口等待')} - - ); - default: - return ( - - {t('未知')} - - ); - } - } - - const renderTimestamp = (timestampInSeconds) => { - const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒 - - const year = date.getFullYear(); // 获取年份 - const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数 - const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数 - const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数 - const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数 - const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数 - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出 - }; - // 修改renderDuration函数以包含颜色逻辑 - function renderDuration(submit_time, finishTime) { - if (!submit_time || !finishTime) return 'N/A'; - - const start = new Date(submit_time); - const finish = new Date(finishTime); - const durationMs = finish - start; - const durationSec = (durationMs / 1000).toFixed(1); - const color = durationSec > 60 ? 'red' : 'green'; - - return ( - - {durationSec} {t('秒')} - - ); - } - const columns = [ - { - title: t('提交时间'), - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{renderTimestamp(text / 1000)}
; - }, - }, - { - title: t('花费时间'), - dataIndex: 'finish_time', // 以finish_time作为dataIndex - key: 'finish_time', - render: (finish, record) => { - // 假设record.start_time是存在的,并且finish是完成时间的时间戳 - return renderDuration(record.submit_time, finish); - }, - }, - { - title: t('渠道'), - dataIndex: 'channel_id', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return ( -
- { - copyText(text); // 假设copyText是用于文本复制的函数 - }} - > - {' '} - {text}{' '} - -
- ); - }, - }, - { - title: t('类型'), - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - title: t('任务ID'), - dataIndex: 'mj_id', - render: (text, record, index) => { - return
{text}
; - }, - }, - { - title: t('提交结果'), - dataIndex: 'code', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return
{renderCode(text)}
; - }, - }, - { - title: t('任务状态'), - dataIndex: 'status', - className: isAdmin() ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - title: t('进度'), - dataIndex: 'progress', - render: (text, record, index) => { - return ( -
- { - // 转换例如100%为数字100,如果text未定义,返回0 - - } -
- ); - }, - }, - { - title: t('结果图片'), - dataIndex: 'image_url', - render: (text, record, index) => { - if (!text) { - return t('无'); - } - return ( - - ); - }, - }, - { - title: 'Prompt', - dataIndex: 'prompt', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - title: 'PromptEn', - dataIndex: 'prompt_en', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - { - title: t('失败原因'), - dataIndex: 'fail_reason', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return t('无'); - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - ]; - - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType, setLogType] = useState(0); - const isAdminUser = isAdmin(); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [showBanner, setShowBanner] = useState(false); - - // 定义模态框图片URL的状态和更新函数 - const [modalImageUrl, setModalImageUrl] = useState(''); - let now = new Date(); - // 初始化start_timestamp为前一天 - const [inputs, setInputs] = useState({ - channel_id: '', - mj_id: '', - start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000), - end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), - }); - const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs; - - const [stat, setStat] = useState({ - quota: 0, - token: 0, - }); - - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const setLogsFormat = (logs) => { - for (let i = 0; i < logs.length; i++) { - logs[i].timestamp2string = timestamp2string(logs[i].created_at); - logs[i].key = '' + logs[i].id; - } - // data.key = '' + data.id - setLogs(logs); - setLogCount(logs.length + ITEMS_PER_PAGE); - // console.log(logCount); - }; - - const loadLogs = async (startIdx) => { - setLoading(true); - - let url = ''; - let localStartTimestamp = Date.parse(start_timestamp); - let localEndTimestamp = Date.parse(end_timestamp); - if (isAdminUser) { - url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } else { - url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } - const res = await API.get(url); - const { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setLogsFormat(data); - } else { - let newLogs = [...logs]; - newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setLogsFormat(newLogs); - } - } else { - showError(message); - } - setLoading(false); - }; - - const pageData = logs.slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE, - ); - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - loadLogs(page - 1).then((r) => {}); - } - }; - - const refresh = async () => { - // setLoading(true); - setActivePage(1); - await loadLogs(0); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, [logType]); - - useEffect(() => { - const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled'); - if (mjNotifyEnabled !== 'true') { - setShowBanner(true); - } - }, []); - - return ( - <> - - {isAdminUser && showBanner ? ( - - ) : ( - <> - )} -
- <> - handleInputChange(value, 'channel_id')} - /> - handleInputChange(value, 'mj_id')} - /> - handleInputChange(value, 'start_timestamp')} - /> - handleInputChange(value, 'end_timestamp')} - /> - - - - - - - - t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: logCount, - }), - }} - loading={loading} - /> - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- setIsModalOpenurl(visible)} - /> - - - ); -}; - -export default LogsTable; diff --git a/web/src/components/ModelPricing.js b/web/src/components/ModelPricing.js deleted file mode 100644 index 16eb08f1..00000000 --- a/web/src/components/ModelPricing.js +++ /dev/null @@ -1,433 +0,0 @@ -import React, { useContext, useEffect, useRef, useMemo, useState } from 'react'; -import { API, copy, showError, showInfo, showSuccess } from '../helpers'; -import { useTranslation } from 'react-i18next'; - -import { - Banner, - Input, - Layout, - Modal, - Space, - Table, - Tag, - Tooltip, - Popover, - ImagePreview, - Button, -} from '@douyinfe/semi-ui'; -import { - IconMore, - IconVerify, - IconUploadError, - IconHelpCircle, -} from '@douyinfe/semi-icons'; -import { UserContext } from '../context/User/index.js'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; - -const ModelPricing = () => { - const { t } = useTranslation(); - const [filteredValue, setFilteredValue] = useState([]); - const compositionRef = useRef({ isComposition: false }); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [modalImageUrl, setModalImageUrl] = useState(''); - const [isModalOpenurl, setIsModalOpenurl] = useState(false); - const [selectedGroup, setSelectedGroup] = useState('default'); - - const rowSelection = useMemo( - () => ({ - onChange: (selectedRowKeys, selectedRows) => { - setSelectedRowKeys(selectedRowKeys); - }, - }), - [], - ); - - const handleChange = (value) => { - if (compositionRef.current.isComposition) { - return; - } - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - const handleCompositionStart = () => { - compositionRef.current.isComposition = true; - }; - - const handleCompositionEnd = (event) => { - compositionRef.current.isComposition = false; - const value = event.target.value; - const newFilteredValue = value ? [value] : []; - setFilteredValue(newFilteredValue); - }; - - function renderQuotaType(type) { - // Ensure all cases are string literals by adding quotes. - switch (type) { - case 1: - return ( - - {t('按次计费')} - - ); - case 0: - return ( - - {t('按量计费')} - - ); - default: - return t('未知'); - } - } - - function renderAvailable(available) { - return available ? ( - {t('您的分组可以使用该模型')} - } - position='top' - key={available} - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} - > - - - ) : null; - } - - const columns = [ - { - title: t('可用性'), - dataIndex: 'available', - render: (text, record, index) => { - // if record.enable_groups contains selectedGroup, then available is true - return renderAvailable(record.enable_groups.includes(selectedGroup)); - }, - sorter: (a, b) => { - const aAvailable = a.enable_groups.includes(selectedGroup); - const bAvailable = b.enable_groups.includes(selectedGroup); - return Number(aAvailable) - Number(bAvailable); - }, - defaultSortOrder: 'descend', - }, - { - title: t('模型名称'), - dataIndex: 'model_name', - render: (text, record, index) => { - return ( - <> - { - copyText(text); - }} - > - {text} - - - ); - }, - onFilter: (value, record) => - record.model_name.toLowerCase().includes(value.toLowerCase()), - filteredValue, - }, - { - title: t('计费类型'), - dataIndex: 'quota_type', - render: (text, record, index) => { - return renderQuotaType(parseInt(text)); - }, - sorter: (a, b) => a.quota_type - b.quota_type, - }, - { - title: t('可用分组'), - dataIndex: 'enable_groups', - render: (text, record, index) => { - // enable_groups is a string array - return ( - - {text.map((group) => { - if (usableGroup[group]) { - if (group === selectedGroup) { - return ( - }> - {group} - - ); - } else { - return ( - { - setSelectedGroup(group); - showInfo( - t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { - group: group, - ratio: groupRatio[group], - }), - ); - }} - > - {group} - - ); - } - } - })} - - ); - }, - }, - { - title: () => ( - - {t('倍率')} - - {t('倍率是为了方便换算不同价格的模型')} -
- {t('点击查看倍率说明')} - - } - position='top' - style={{ - backgroundColor: 'rgba(var(--semi-blue-4),1)', - borderColor: 'rgba(var(--semi-blue-4),1)', - color: 'var(--semi-color-white)', - borderWidth: 1, - borderStyle: 'solid', - }} - > - { - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> -
-
- ), - dataIndex: 'model_ratio', - render: (text, record, index) => { - let content = text; - let completionRatio = parseFloat(record.completion_ratio.toFixed(3)); - content = ( - <> - - {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')} - -
- - {t('补全倍率')}: - {record.quota_type === 0 ? completionRatio : t('无')} - -
- - {t('分组倍率')}:{groupRatio[selectedGroup]} - - - ); - return
{content}
; - }, - }, - { - title: t('模型价格'), - dataIndex: 'model_price', - render: (text, record, index) => { - let content = text; - if (record.quota_type === 0) { - // 这里的 *2 是因为 1倍率=0.002刀,请勿删除 - let inputRatioPrice = - record.model_ratio * 2 * groupRatio[selectedGroup]; - let completionRatioPrice = - record.model_ratio * - record.completion_ratio * - 2 * - groupRatio[selectedGroup]; - content = ( - <> - - {t('提示')} ${inputRatioPrice} / 1M tokens - -
- - {t('补全')} ${completionRatioPrice} / 1M tokens - - - ); - } else { - let price = parseFloat(text) * groupRatio[selectedGroup]; - content = ( - <> - ${t('模型价格')}:${price} - - ); - } - return
{content}
; - }, - }, - ]; - - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [userState, userDispatch] = useContext(UserContext); - const [groupRatio, setGroupRatio] = useState({}); - const [usableGroup, setUsableGroup] = useState({}); - - const setModelsFormat = (models, groupRatio) => { - for (let i = 0; i < models.length; i++) { - models[i].key = models[i].model_name; - models[i].group_ratio = groupRatio[models[i].model_name]; - } - // sort by quota_type - models.sort((a, b) => { - return a.quota_type - b.quota_type; - }); - - // sort by model_name, start with gpt is max, other use localeCompare - models.sort((a, b) => { - if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) { - return -1; - } else if ( - !a.model_name.startsWith('gpt') && - b.model_name.startsWith('gpt') - ) { - return 1; - } else { - return a.model_name.localeCompare(b.model_name); - } - }); - - setModels(models); - }; - - const loadPricing = async () => { - setLoading(true); - - let url = ''; - url = `/api/pricing`; - const res = await API.get(url); - const { success, message, data, group_ratio, usable_group } = res.data; - if (success) { - setGroupRatio(group_ratio); - setUsableGroup(usable_group); - setSelectedGroup(userState.user ? userState.user.group : 'default'); - setModelsFormat(data, group_ratio); - } else { - showError(message); - } - setLoading(false); - }; - - const refresh = async () => { - await loadPricing(); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, []); - - return ( - <> - - {userState.user ? ( - - ) : ( - - )} -
- - {t( - '按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)', - )} - - } - closeIcon='null' - /> -
- - - - -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: models.length, - }), - pageSize: models.length, - showSizeChanger: false, - }} - rowSelection={rowSelection} - /> - setIsModalOpenurl(visible)} - /> - - - ); -}; - -export default ModelPricing; diff --git a/web/src/components/PasswordResetConfirm.js b/web/src/components/PasswordResetConfirm.js deleted file mode 100644 index 222c8add..00000000 --- a/web/src/components/PasswordResetConfirm.js +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, copy, showError, showNotice } from '../helpers'; -import { useSearchParams } from 'react-router-dom'; - -const PasswordResetConfirm = () => { - const [inputs, setInputs] = useState({ - email: '', - token: '', - }); - const { email, token } = inputs; - - const [loading, setLoading] = useState(false); - - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - const [newPassword, setNewPassword] = useState(''); - - const [searchParams, setSearchParams] = useSearchParams(); - useEffect(() => { - let token = searchParams.get('token'); - let email = searchParams.get('email'); - setInputs({ - token, - email, - }); - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - async function handleSubmit(e) { - setDisableButton(true); - if (!email) return; - setLoading(true); - const res = await API.post(`/api/user/reset`, { - email, - token, - }); - const { success, message } = res.data; - if (success) { - let password = res.data.data; - setNewPassword(password); - await copy(password); - showNotice(`新密码已复制到剪贴板:${password}`); - } else { - showError(message); - } - setLoading(false); - } - - return ( - - -
- 密码重置确认 -
-
- - - {newPassword && ( - { - e.target.select(); - navigator.clipboard.writeText(newPassword); - showNotice(`密码已复制到剪贴板:${newPassword}`); - }} - /> - )} - - - -
-
- ); -}; - -export default PasswordResetConfirm; diff --git a/web/src/components/PasswordResetForm.js b/web/src/components/PasswordResetForm.js deleted file mode 100644 index 631d83be..00000000 --- a/web/src/components/PasswordResetForm.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; -import { API, showError, showInfo, showSuccess } from '../helpers'; -import Turnstile from 'react-turnstile'; - -const PasswordResetForm = () => { - const [inputs, setInputs] = useState({ - email: '', - }); - const { email } = inputs; - - const [loading, setLoading] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); - }, [disableButton, countdown]); - - function handleChange(e) { - const { name, value } = e.target; - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - setDisableButton(true); - if (!email) return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess('重置邮件发送成功,请检查邮箱!'); - setInputs({ ...inputs, email: '' }); - } else { - showError(message); - } - setLoading(false); - } - - return ( - - -
- 密码重置 -
-
- - - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} - - - -
-
- ); -}; - -export default PasswordResetForm; diff --git a/web/src/components/PersonalSetting.js b/web/src/components/PersonalSetting.js deleted file mode 100644 index 0f52c319..00000000 --- a/web/src/components/PersonalSetting.js +++ /dev/null @@ -1,1193 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - API, - copy, - isRoot, - showError, - showInfo, - showSuccess, -} from '../helpers'; -import Turnstile from 'react-turnstile'; -import { UserContext } from '../context/User'; -import { - onGitHubOAuthClicked, - onOIDCClicked, - onLinuxDOOAuthClicked, -} from './utils'; -import { - Avatar, - Banner, - Button, - Card, - Descriptions, - Image, - Input, - InputNumber, - Layout, - Modal, - Space, - Tag, - Typography, - Collapsible, - Select, - Radio, - RadioGroup, - AutoComplete, - Checkbox, - Tabs, - TabPane, -} from '@douyinfe/semi-ui'; -import { - getQuotaPerUnit, - renderQuota, - renderQuotaWithPrompt, - stringToColor, -} from '../helpers/render'; -import TelegramLoginButton from 'react-telegram-login'; -import { useTranslation } from 'react-i18next'; - -const PersonalSetting = () => { - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - const { t } = useTranslation(); - - const [inputs, setInputs] = useState({ - wechat_verification_code: '', - email_verification_code: '', - email: '', - self_account_deletion_confirmation: '', - original_password: '', - set_new_password: '', - set_new_password_confirmation: '', - }); - const [status, setStatus] = useState({}); - const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); - const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); - const [showEmailBindModal, setShowEmailBindModal] = useState(false); - const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const [disableButton, setDisableButton] = useState(false); - const [countdown, setCountdown] = useState(30); - const [affLink, setAffLink] = useState(''); - const [systemToken, setSystemToken] = useState(''); - const [models, setModels] = useState([]); - const [openTransfer, setOpenTransfer] = useState(false); - const [transferAmount, setTransferAmount] = useState(0); - const [isModelsExpanded, setIsModelsExpanded] = useState(() => { - // Initialize from localStorage if available - const savedState = localStorage.getItem('modelsExpanded'); - return savedState ? JSON.parse(savedState) : false; - }); - const MODELS_DISPLAY_COUNT = 10; // 默认显示的模型数量 - const [notificationSettings, setNotificationSettings] = useState({ - warningType: 'email', - warningThreshold: 100000, - webhookUrl: '', - webhookSecret: '', - notificationEmail: '', - acceptUnsetModelRatioModel: false, - }); - const [showWebhookDocs, setShowWebhookDocs] = useState(false); - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - getUserData().then((res) => { - console.log(userState); - }); - loadModels().then(); - getAffLink().then(); - setTransferAmount(getQuotaPerUnit()); - }, []); - - useEffect(() => { - let countdownInterval = null; - if (disableButton && countdown > 0) { - countdownInterval = setInterval(() => { - setCountdown(countdown - 1); - }, 1000); - } else if (countdown === 0) { - setDisableButton(false); - setCountdown(30); - } - return () => clearInterval(countdownInterval); // Clean up on unmount - }, [disableButton, countdown]); - - useEffect(() => { - if (userState?.user?.setting) { - const settings = JSON.parse(userState.user.setting); - setNotificationSettings({ - warningType: settings.notify_type || 'email', - warningThreshold: settings.quota_warning_threshold || 500000, - webhookUrl: settings.webhook_url || '', - webhookSecret: settings.webhook_secret || '', - notificationEmail: settings.notification_email || '', - acceptUnsetModelRatioModel: - settings.accept_unset_model_ratio_model || false, - }); - } - }, [userState?.user?.setting]); - - // Save models expanded state to localStorage whenever it changes - useEffect(() => { - localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded)); - }, [isModelsExpanded]); - - const handleInputChange = (name, value) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const generateAccessToken = async () => { - const res = await API.get('/api/user/token'); - const { success, message, data } = res.data; - if (success) { - setSystemToken(data); - await copy(data); - showSuccess(t('令牌已重置并已复制到剪贴板')); - } else { - showError(message); - } - }; - - const getAffLink = async () => { - const res = await API.get('/api/user/aff'); - const { success, message, data } = res.data; - if (success) { - let link = `${window.location.origin}/register?aff=${data}`; - setAffLink(link); - } else { - showError(message); - } - }; - - const getUserData = async () => { - let res = await API.get(`/api/user/self`); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - } else { - showError(message); - } - }; - - const loadModels = async () => { - let res = await API.get(`/api/user/models`); - const { success, message, data } = res.data; - if (success) { - if (data != null) { - setModels(data); - } - } else { - showError(message); - } - }; - - const handleAffLinkClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(t('邀请链接已复制到剪切板')); - }; - - const handleSystemTokenClick = async (e) => { - e.target.select(); - await copy(e.target.value); - showSuccess(t('系统令牌已复制到剪切板')); - }; - - const deleteAccount = async () => { - if (inputs.self_account_deletion_confirmation !== userState.user.username) { - showError(t('请输入你的账户名以确认删除!')); - return; - } - - const res = await API.delete('/api/user/self'); - const { success, message } = res.data; - - if (success) { - showSuccess(t('账户已删除!')); - await API.get('/api/user/logout'); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } else { - showError(message); - } - }; - - const bindWeChat = async () => { - if (inputs.wechat_verification_code === '') return; - const res = await API.get( - `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('微信账户绑定成功!')); - setShowWeChatBindModal(false); - } else { - showError(message); - } - }; - - const changePassword = async () => { - if (inputs.original_password === '') { - showError(t('请输入原密码!')); - return; - } - if (inputs.set_new_password === '') { - showError(t('请输入新密码!')); - return; - } - if (inputs.original_password === inputs.set_new_password) { - showError(t('新密码需要和原密码不一致!')); - return; - } - if (inputs.set_new_password !== inputs.set_new_password_confirmation) { - showError(t('两次输入的密码不一致!')); - return; - } - const res = await API.put(`/api/user/self`, { - original_password: inputs.original_password, - password: inputs.set_new_password, - }); - const { success, message } = res.data; - if (success) { - showSuccess(t('密码修改成功!')); - setShowWeChatBindModal(false); - } else { - showError(message); - } - setShowChangePasswordModal(false); - }; - - const transfer = async () => { - if (transferAmount < getQuotaPerUnit()) { - showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit())); - return; - } - const res = await API.post(`/api/user/aff_transfer`, { - quota: transferAmount, - }); - const { success, message } = res.data; - if (success) { - showSuccess(message); - setOpenTransfer(false); - getUserData().then(); - } else { - showError(message); - } - }; - - const sendVerificationCode = async () => { - if (inputs.email === '') { - showError(t('请输入邮箱!')); - return; - } - setDisableButton(true); - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('验证码发送成功,请检查邮箱!')); - } else { - showError(message); - } - setLoading(false); - }; - - const bindEmail = async () => { - if (inputs.email_verification_code === '') { - showError(t('请输入邮箱验证码!')); - return; - } - setLoading(true); - const res = await API.get( - `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess(t('邮箱账户绑定成功!')); - setShowEmailBindModal(false); - userState.user.email = inputs.email; - } else { - showError(message); - } - setLoading(false); - }; - - const getUsername = () => { - if (userState.user) { - return userState.user.username; - } else { - return 'null'; - } - }; - - const handleCancel = () => { - setOpenTransfer(false); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制:') + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - const handleNotificationSettingChange = (type, value) => { - setNotificationSettings((prev) => ({ - ...prev, - [type]: value.target ? value.target.value : value, // 处理 Radio 事件对象 - })); - }; - - const saveNotificationSettings = async () => { - try { - const res = await API.put('/api/user/setting', { - notify_type: notificationSettings.warningType, - quota_warning_threshold: parseFloat( - notificationSettings.warningThreshold, - ), - webhook_url: notificationSettings.webhookUrl, - webhook_secret: notificationSettings.webhookSecret, - notification_email: notificationSettings.notificationEmail, - accept_unset_model_ratio_model: - notificationSettings.acceptUnsetModelRatioModel, - }); - - if (res.data.success) { - showSuccess(t('通知设置已更新')); - await getUserData(); - } else { - showError(res.data.message); - } - } catch (error) { - showError(t('更新通知设置失败')); - } - }; - - return ( -
- - - -
- - {t('可用额度')} - {renderQuotaWithPrompt(userState?.user?.aff_quota)} - - -
-
- - {t('划转额度')} - {renderQuotaWithPrompt(transferAmount)}{' '} - {t('最低') + renderQuota(getQuotaPerUnit())} - -
- setTransferAmount(value)} - disabled={false} - > -
-
-
-
- - {typeof getUsername() === 'string' && - getUsername().slice(0, 1)} - - } - title={{getUsername()}} - description={ - isRoot() ? ( - {t('管理员')} - ) : ( - {t('普通用户')} - ) - } - > - } - headerExtraContent={ - <> - - {'ID: ' + userState?.user?.id} - {userState?.user?.group} - - - } - footer={ - <> -
- - {t('可用模型')} - -
-
- {models.length <= MODELS_DISPLAY_COUNT ? ( - - {models.map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - - ) : ( - <> - - - {models.map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - setIsModelsExpanded(false)} - > - {t('收起')} - - - - {!isModelsExpanded && ( - - {models - .slice(0, MODELS_DISPLAY_COUNT) - .map((model) => ( - { - copyText(model); - }} - > - {model} - - ))} - setIsModelsExpanded(true)} - > - {t('更多')} {models.length - MODELS_DISPLAY_COUNT}{' '} - {t('个模型')} - - - )} - - )} -
- - } - > - - - {renderQuota(userState?.user?.quota)} - - - {renderQuota(userState?.user?.used_quota)} - - - {userState.user?.request_count} - - -
- - {t('邀请链接')} - -
- } - > - {t('邀请信息')} -
- - - - {renderQuota(userState?.user?.aff_quota)} - - - - - {renderQuota(userState?.user?.aff_history_quota)} - - - {userState?.user?.aff_count} - - -
- - - {t('个人信息')} -
- {t('邮箱')} -
-
- -
-
- -
-
-
-
- {t('微信')} -
-
- -
-
- -
-
-
-
- {t('GitHub')} -
-
- -
-
- -
-
-
-
- {t('OIDC')} -
-
- -
-
- -
-
-
-
- {t('Telegram')} -
-
- -
-
- {status.telegram_oauth ? ( - userState.user.telegram_id !== '' ? ( - - ) : ( - - ) - ) : ( - - )} -
-
-
-
- {t('LinuxDO')} -
-
- -
-
- -
-
-
-
- - - - - - - {systemToken && ( - - )} - setShowWeChatBindModal(false)} - visible={showWeChatBindModal} - size={'small'} - > - -
-

- 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) -

-
- - handleInputChange('wechat_verification_code', v) - } - /> - -
-
-
- - - -
- {t('通知方式')} -
- - handleNotificationSettingChange('warningType', value) - } - > - {t('邮件通知')} - {t('Webhook通知')} - -
-
- {notificationSettings.warningType === 'webhook' && ( - <> -
- - {t('Webhook地址')} - -
- - handleNotificationSettingChange('webhookUrl', val) - } - placeholder={t( - '请输入Webhook地址,例如: https://example.com/webhook', - )} - /> - - {t( - '只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求', - )} - - -
- setShowWebhookDocs(!showWebhookDocs) - } - > - {t('Webhook请求结构')}{' '} - {showWebhookDocs ? '▼' : '▶'} -
- -
-                                {`{
-    "type": "quota_exceed",      // 通知类型
-    "title": "标题",             // 通知标题
-    "content": "通知内容",       // 通知内容,支持 {{value}} 变量占位符
-    "values": ["值1", "值2"],    // 按顺序替换content中的 {{value}} 占位符
-    "timestamp": 1739950503      // 时间戳
-}
-
-示例:
-{
-    "type": "quota_exceed",
-    "title": "额度预警通知",
-    "content": "您的额度即将用尽,当前剩余额度为 {{value}}",
-    "values": ["$0.99"],
-    "timestamp": 1739950503
-}`}
-                              
-
-
-
-
-
- - {t('接口凭证(可选)')} - -
- - handleNotificationSettingChange( - 'webhookSecret', - val, - ) - } - placeholder={t('请输入密钥')} - /> - - {t( - '密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性', - )} - - - {t('Authorization: Bearer your-secret-key')} - -
-
- - )} - {notificationSettings.warningType === 'email' && ( -
- {t('通知邮箱')} -
- - handleNotificationSettingChange( - 'notificationEmail', - val, - ) - } - placeholder={t('留空则使用账号绑定的邮箱')} - /> - - {t( - '设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱', - )} - -
-
- )} -
- - {t('额度预警阈值')}{' '} - {renderQuotaWithPrompt( - notificationSettings.warningThreshold, - )} - -
- - handleNotificationSettingChange( - 'warningThreshold', - val, - ) - } - style={{ width: 200 }} - placeholder={t('请输入预警额度')} - data={[ - { value: 100000, label: '0.2$' }, - { value: 500000, label: '1$' }, - { value: 1000000, label: '5$' }, - { value: 5000000, label: '10$' }, - ]} - /> -
- - {t( - '当剩余额度低于此数值时,系统将通过选择的方式发送通知', - )} - -
-
- -
- - {t('接受未设置价格模型')} - -
- - handleNotificationSettingChange( - 'acceptUnsetModelRatioModel', - e.target.checked, - ) - } - > - {t('接受未设置价格模型')} - - - {t( - '当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用', - )} - -
-
-
-
-
- -
-
- setShowEmailBindModal(false)} - onOk={bindEmail} - visible={showEmailBindModal} - size={'small'} - centered={true} - maskClosable={false} - > - - {t('绑定邮箱地址')} - -
- handleInputChange('email', value)} - name='email' - type='email' - /> - -
-
- - handleInputChange('email_verification_code', value) - } - /> -
- {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
- setShowAccountDeleteModal(false)} - visible={showAccountDeleteModal} - size={'small'} - centered={true} - onOk={deleteAccount} - > -
- -
-
- - handleInputChange( - 'self_account_deletion_confirmation', - value, - ) - } - /> - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
-
- setShowChangePasswordModal(false)} - visible={showChangePasswordModal} - size={'small'} - centered={true} - onOk={changePassword} - > -
- - handleInputChange('original_password', value) - } - /> - - handleInputChange('set_new_password', value) - } - /> - - handleInputChange('set_new_password_confirmation', value) - } - /> - {turnstileEnabled ? ( - { - setTurnstileToken(token); - }} - /> - ) : ( - <> - )} -
-
-
- - - - ); -}; - -export default PersonalSetting; diff --git a/web/src/components/PrivateRoute.js b/web/src/components/PrivateRoute.js deleted file mode 100644 index ca938c41..00000000 --- a/web/src/components/PrivateRoute.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Navigate } from 'react-router-dom'; - -import { history } from '../helpers'; - -function PrivateRoute({ children }) { - if (!localStorage.getItem('user')) { - return ; - } - return children; -} - -export { PrivateRoute }; diff --git a/web/src/components/RedemptionsTable.js b/web/src/components/RedemptionsTable.js deleted file mode 100644 index f4efca06..00000000 --- a/web/src/components/RedemptionsTable.js +++ /dev/null @@ -1,449 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - API, - copy, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { ITEMS_PER_PAGE } from '../constants'; -import { renderQuota } from '../helpers/render'; -import { - Button, - Divider, - Form, - Modal, - Popconfirm, - Popover, - Table, - Tag, -} from '@douyinfe/semi-ui'; -import EditRedemption from '../pages/Redemption/EditRedemption'; -import { useTranslation } from 'react-i18next'; - -function renderTimestamp(timestamp) { - return <>{timestamp2string(timestamp)}; -} - -const RedemptionsTable = () => { - const { t } = useTranslation(); - - const renderStatus = (status) => { - switch (status) { - case 1: - return ( - - {t('未使用')} - - ); - case 2: - return ( - - {t('已禁用')} - - ); - case 3: - return ( - - {t('已使用')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const columns = [ - { - title: t('ID'), - dataIndex: 'id', - }, - { - title: t('名称'), - dataIndex: 'name', - }, - { - title: t('状态'), - dataIndex: 'status', - key: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - { - title: t('额度'), - dataIndex: 'quota', - render: (text, record, index) => { - return
{renderQuota(parseInt(text))}
; - }, - }, - { - title: t('创建时间'), - dataIndex: 'created_time', - render: (text, record, index) => { - return
{renderTimestamp(text)}
; - }, - }, - { - title: t('兑换人ID'), - dataIndex: 'used_user_id', - render: (text, record, index) => { - return
{text === 0 ? t('无') : text}
; - }, - }, - { - title: '', - dataIndex: 'operate', - render: (text, record, index) => ( -
- - - - - { - manageRedemption(record.id, 'delete', record).then(() => { - removeRecord(record.key); - }); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - -
- ), - }, - ]; - - const [redemptions, setRedemptions] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE); - const [selectedKeys, setSelectedKeys] = useState([]); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [editingRedemption, setEditingRedemption] = useState({ - id: undefined, - }); - const [showEdit, setShowEdit] = useState(false); - - const closeEdit = () => { - setShowEdit(false); - }; - - const setRedemptionFormat = (redeptions) => { - setRedemptions(redeptions); - }; - - const loadRedemptions = async (startIdx, pageSize) => { - const res = await API.get( - `/api/redemption/?p=${startIdx}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setTokenCount(data.total); - setRedemptionFormat(newPageData); - } else { - showError(message); - } - setLoading(false); - }; - - const removeRecord = (key) => { - let newDataSource = [...redemptions]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.key === key); - - if (idx > -1) { - newDataSource.splice(idx, 1); - setRedemptions(newDataSource); - } - } - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess(t('已复制到剪贴板!')); - } else { - // setSearchKeyword(text); - Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text }); - } - }; - - const onPaginationChange = (e, { activePage }) => { - (async () => { - if (activePage === Math.ceil(redemptions.length / pageSize) + 1) { - await loadRedemptions(activePage - 1, pageSize); - } - setActivePage(activePage); - })(); - }; - - useEffect(() => { - loadRedemptions(0, pageSize) - .then() - .catch((reason) => { - showError(reason); - }); - }, []); - - const refresh = async () => { - await loadRedemptions(activePage - 1, pageSize); - }; - - const manageRedemption = async (id, action, record) => { - let data = { id }; - let res; - switch (action) { - case 'delete': - res = await API.delete(`/api/redemption/${id}/`); - break; - case 'enable': - data.status = 1; - res = await API.put('/api/redemption/?status_only=true', data); - break; - case 'disable': - data.status = 2; - res = await API.put('/api/redemption/?status_only=true', data); - break; - } - const { success, message } = res.data; - if (success) { - showSuccess(t('操作成功完成!')); - let redemption = res.data.data; - let newRedemptions = [...redemptions]; - // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; - if (action === 'delete') { - } else { - record.status = redemption.status; - } - setRedemptions(newRedemptions); - } else { - showError(message); - } - }; - - const searchRedemptions = async (keyword, page, pageSize) => { - if (searchKeyword === '') { - await loadRedemptions(page, pageSize); - return; - } - setSearching(true); - const res = await API.get( - `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setTokenCount(data.total); - setRedemptionFormat(newPageData); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const sortRedemption = (key) => { - if (redemptions.length === 0) return; - setLoading(true); - let sortedRedemptions = [...redemptions]; - sortedRedemptions.sort((a, b) => { - return ('' + a[key]).localeCompare(b[key]); - }); - if (sortedRedemptions[0].id === redemptions[0].id) { - sortedRedemptions.reverse(); - } - setRedemptions(sortedRedemptions); - setLoading(false); - }; - - const handlePageChange = (page) => { - setActivePage(page); - if (searchKeyword === '') { - loadRedemptions(page, pageSize).then(); - } else { - searchRedemptions(searchKeyword, page, pageSize).then(); - } - }; - - let pageData = redemptions; - const rowSelection = { - onSelect: (record, selected) => {}, - onSelectAll: (selected, selectedRows) => {}, - onChange: (selectedRowKeys, selectedRows) => { - setSelectedKeys(selectedRows); - }, - }; - - const handleRow = (record, index) => { - if (record.status !== 1) { - return { - style: { - background: 'var(--semi-color-disabled-border)', - }, - }; - } else { - return {}; - } - }; - - return ( - <> - -
{ - searchRedemptions(searchKeyword, activePage, pageSize).then(); - }} - > - - - -
- - -
- -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: tokenCount, - }), - onPageSizeChange: (size) => { - setPageSize(size); - setActivePage(1); - if (searchKeyword === '') { - loadRedemptions(1, size).then(); - } else { - searchRedemptions(searchKeyword, 1, size).then(); - } - }, - onPageChange: handlePageChange, - }} - loading={loading} - rowSelection={rowSelection} - onRow={handleRow} - >
- - ); -}; - -export default RedemptionsTable; diff --git a/web/src/components/RegisterForm.js b/web/src/components/RegisterForm.js deleted file mode 100644 index 50fe4def..00000000 --- a/web/src/components/RegisterForm.js +++ /dev/null @@ -1,434 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { - API, - getLogo, - showError, - showInfo, - showSuccess, - updateAPI, -} from '../helpers'; -import Turnstile from 'react-turnstile'; -import { - Button, - Card, - Divider, - Form, - Icon, - Layout, - Modal, -} from '@douyinfe/semi-ui'; -import Title from '@douyinfe/semi-ui/lib/es/typography/title'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; -import { IconGithubLogo } from '@douyinfe/semi-icons'; -import { - onGitHubOAuthClicked, - onLinuxDOOAuthClicked, - onOIDCClicked, -} from './utils.js'; -import OIDCIcon from './OIDCIcon.js'; -import LinuxDoIcon from './LinuxDoIcon.js'; -import WeChatIcon from './WeChatIcon.js'; -import TelegramLoginButton from 'react-telegram-login/src'; -import { setUserData } from '../helpers/data.js'; -import { UserContext } from '../context/User/index.js'; -import { useTranslation } from 'react-i18next'; - -const RegisterForm = () => { - const { t } = useTranslation(); - const [inputs, setInputs] = useState({ - username: '', - password: '', - password2: '', - email: '', - verification_code: '', - }); - const { username, password, password2 } = inputs; - const [showEmailVerification, setShowEmailVerification] = useState(false); - const [userState, userDispatch] = useContext(UserContext); - const [turnstileEnabled, setTurnstileEnabled] = useState(false); - const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); - const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); - const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); - const [status, setStatus] = useState({}); - let navigate = useNavigate(); - const logo = getLogo(); - - let affCode = new URLSearchParams(window.location.search).get('aff'); - if (affCode) { - localStorage.setItem('aff', affCode); - } - - useEffect(() => { - let status = localStorage.getItem('status'); - if (status) { - status = JSON.parse(status); - setStatus(status); - setShowEmailVerification(status.email_verification); - if (status.turnstile_check) { - setTurnstileEnabled(true); - setTurnstileSiteKey(status.turnstile_site_key); - } - } - }); - - const onWeChatLoginClicked = () => { - setShowWeChatLoginModal(true); - }; - - const onSubmitWeChatVerificationCode = async () => { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - const res = await API.get( - `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, - ); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - setUserData(data); - updateAPI(); - navigate('/'); - showSuccess('登录成功!'); - setShowWeChatLoginModal(false); - } else { - showError(message); - } - }; - - function handleChange(name, value) { - setInputs((inputs) => ({ ...inputs, [name]: value })); - } - - async function handleSubmit(e) { - if (password.length < 8) { - showInfo('密码长度不得小于 8 位!'); - return; - } - if (password !== password2) { - showInfo('两次输入的密码不一致'); - return; - } - if (username && password) { - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - if (!affCode) { - affCode = localStorage.getItem('aff'); - } - inputs.aff_code = affCode; - const res = await API.post( - `/api/user/register?turnstile=${turnstileToken}`, - inputs, - ); - const { success, message } = res.data; - if (success) { - navigate('/login'); - showSuccess('注册成功!'); - } else { - showError(message); - } - setLoading(false); - } - } - - const sendVerificationCode = async () => { - if (inputs.email === '') return; - if (turnstileEnabled && turnstileToken === '') { - showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); - return; - } - setLoading(true); - const res = await API.get( - `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, - ); - const { success, message } = res.data; - if (success) { - showSuccess('验证码发送成功,请检查你的邮箱!'); - } else { - showError(message); - } - setLoading(false); - }; - - const onTelegramLoginClicked = async (response) => { - const fields = [ - 'id', - 'first_name', - 'last_name', - 'username', - 'photo_url', - 'auth_date', - 'hash', - 'lang', - ]; - const params = {}; - fields.forEach((field) => { - if (response[field]) { - params[field] = response[field]; - } - }); - const res = await API.get(`/api/oauth/telegram/login`, { params }); - const { success, message, data } = res.data; - if (success) { - userDispatch({ type: 'login', payload: data }); - localStorage.setItem('user', JSON.stringify(data)); - showSuccess('登录成功!'); - setUserData(data); - updateAPI(); - navigate('/'); - } else { - showError(message); - } - }; - - return ( -
- - - -
-
- - - {t('新用户注册')} - -
- handleChange('username', value)} - /> - handleChange('password', value)} - /> - handleChange('password2', value)} - /> - {showEmailVerification ? ( - <> - handleChange('email', value)} - name='email' - type='email' - suffix={ - - } - /> - - handleChange('verification_code', value) - } - name='verification_code' - /> - - ) : ( - <> - )} - - -
- - {t('已有账户?')} - {t('点击登录')} - -
- {status.github_oauth || - status.oidc_enabled || - status.wechat_login || - status.telegram_oauth || - status.linuxdo_oauth ? ( - <> - - {t('第三方登录')} - -
- {status.github_oauth ? ( -
- {status.telegram_oauth ? ( - <> -
- -
- - ) : ( - <> - )} - - ) : ( - <> - )} -
- setShowWeChatLoginModal(false)} - okText={t('登录')} - size={'small'} - centered={true} - > -
- -
-
-

- {t( - '微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)', - )} -

-
-
- - handleChange('wechat_verification_code', value) - } - /> - -
- {turnstileEnabled ? ( -
- { - setTurnstileToken(token); - }} - /> -
- ) : ( - <> - )} -
-
-
-
-
- ); -}; - -export default RegisterForm; diff --git a/web/src/components/SiderBar.js b/web/src/components/SiderBar.js deleted file mode 100644 index 25b350d1..00000000 --- a/web/src/components/SiderBar.js +++ /dev/null @@ -1,535 +0,0 @@ -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate, useLocation } from 'react-router-dom'; -import { UserContext } from '../context/User'; -import { StatusContext } from '../context/Status'; -import { useTranslation } from 'react-i18next'; - -import { - API, - getLogo, - getSystemName, - isAdmin, - isMobile, - showError, -} from '../helpers'; -import '../index.css'; - -import { - IconCalendarClock, - IconChecklistStroked, - IconComment, - IconCommentStroked, - IconCreditCard, - IconGift, - IconHelpCircle, - IconHistogram, - IconHome, - IconImage, - IconKey, - IconLayers, - IconPriceTag, - IconSetting, - IconUser, -} from '@douyinfe/semi-icons'; -import { - Avatar, - Dropdown, - Layout, - Nav, - Switch, - Divider, -} from '@douyinfe/semi-ui'; -import { setStatusData } from '../helpers/data.js'; -import { stringToColor } from '../helpers/render.js'; -import { useSetTheme, useTheme } from '../context/Theme/index.js'; -import { StyleContext } from '../context/Style/index.js'; -import Text from '@douyinfe/semi-ui/lib/es/typography/text'; - -// 自定义侧边栏按钮样式 -const navItemStyle = { - borderRadius: '6px', - margin: '4px 8px', -}; - -// 自定义侧边栏按钮悬停样式 -const navItemHoverStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', -}; - -// 自定义侧边栏按钮选中样式 -const navItemSelectedStyle = { - backgroundColor: 'var(--semi-color-primary-light-default)', - color: 'var(--semi-color-primary)', - fontWeight: '600', -}; - -// 自定义图标样式 -const iconStyle = (itemKey, selectedKeys) => { - return { - fontSize: '18px', - color: selectedKeys.includes(itemKey) - ? 'var(--semi-color-primary)' - : 'var(--semi-color-text-2)', - }; -}; - -// Define routerMap as a constant outside the component -const routerMap = { - home: '/', - channel: '/channel', - token: '/token', - redemption: '/redemption', - topup: '/topup', - user: '/user', - log: '/log', - midjourney: '/midjourney', - setting: '/setting', - about: '/about', - detail: '/detail', - pricing: '/pricing', - task: '/task', - playground: '/playground', - personal: '/personal', -}; - -const SiderBar = () => { - const { t } = useTranslation(); - const [styleState, styleDispatch] = useContext(StyleContext); - const [statusState, statusDispatch] = useContext(StatusContext); - const defaultIsCollapsed = - localStorage.getItem('default_collapse_sidebar') === 'true'; - - const [selectedKeys, setSelectedKeys] = useState(['home']); - const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed); - const [chatItems, setChatItems] = useState([]); - const [openedKeys, setOpenedKeys] = useState([]); - const theme = useTheme(); - const setTheme = useSetTheme(); - const location = useLocation(); - const [routerMapState, setRouterMapState] = useState(routerMap); - - // 预先计算所有可能的图标样式 - const allItemKeys = useMemo(() => { - const keys = [ - 'home', - 'channel', - 'token', - 'redemption', - 'topup', - 'user', - 'log', - 'midjourney', - 'setting', - 'about', - 'chat', - 'detail', - 'pricing', - 'task', - 'playground', - 'personal', - ]; - // 添加聊天项的keys - for (let i = 0; i < chatItems.length; i++) { - keys.push('chat' + i); - } - return keys; - }, [chatItems]); - - // 使用useMemo一次性计算所有图标样式 - const iconStyles = useMemo(() => { - const styles = {}; - allItemKeys.forEach((key) => { - styles[key] = iconStyle(key, selectedKeys); - }); - return styles; - }, [allItemKeys, selectedKeys]); - - const workspaceItems = useMemo( - () => [ - { - text: t('数据看板'), - itemKey: 'detail', - to: '/detail', - icon: , - className: - localStorage.getItem('enable_data_export') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('API令牌'), - itemKey: 'token', - to: '/token', - icon: , - }, - { - text: t('使用日志'), - itemKey: 'log', - to: '/log', - icon: , - }, - { - text: t('绘图日志'), - itemKey: 'midjourney', - to: '/midjourney', - icon: , - className: - localStorage.getItem('enable_drawing') === 'true' - ? '' - : 'tableHiddle', - }, - { - text: t('任务日志'), - itemKey: 'task', - to: '/task', - icon: , - className: - localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', - }, - ], - [ - localStorage.getItem('enable_data_export'), - localStorage.getItem('enable_drawing'), - localStorage.getItem('enable_task'), - t, - ], - ); - - const financeItems = useMemo( - () => [ - { - text: t('钱包'), - itemKey: 'topup', - to: '/topup', - icon: , - }, - { - text: t('个人设置'), - itemKey: 'personal', - to: '/personal', - icon: , - }, - ], - [t], - ); - - const adminItems = useMemo( - () => [ - { - text: t('渠道'), - itemKey: 'channel', - to: '/channel', - icon: , - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('兑换码'), - itemKey: 'redemption', - to: '/redemption', - icon: , - className: isAdmin() ? '' : 'tableHiddle', - }, - { - text: t('用户管理'), - itemKey: 'user', - to: '/user', - icon: , - }, - { - text: t('系统设置'), - itemKey: 'setting', - to: '/setting', - icon: , - }, - ], - [isAdmin(), t], - ); - - const chatMenuItems = useMemo( - () => [ - { - text: 'Playground', - itemKey: 'playground', - to: '/playground', - icon: , - }, - { - text: t('聊天'), - itemKey: 'chat', - items: chatItems, - icon: , - }, - ], - [chatItems, t], - ); - - // Function to update router map with chat routes - const updateRouterMapWithChats = (chats) => { - const newRouterMap = { ...routerMap }; - - if (Array.isArray(chats) && chats.length > 0) { - for (let i = 0; i < chats.length; i++) { - newRouterMap['chat' + i] = '/chat/' + i; - } - } - - setRouterMapState(newRouterMap); - return newRouterMap; - }; - - // Update the useEffect for chat items - useEffect(() => { - let chats = localStorage.getItem('chats'); - if (chats) { - try { - chats = JSON.parse(chats); - if (Array.isArray(chats)) { - let chatItems = []; - for (let i = 0; i < chats.length; i++) { - let chat = {}; - for (let key in chats[i]) { - chat.text = key; - chat.itemKey = 'chat' + i; - chat.to = '/chat/' + i; - } - chatItems.push(chat); - } - setChatItems(chatItems); - - // Update router map with chat routes - updateRouterMapWithChats(chats); - } - } catch (e) { - console.error(e); - showError('聊天数据解析失败'); - } - } - }, []); - - // Update the useEffect for route selection - useEffect(() => { - const currentPath = location.pathname; - let matchingKey = Object.keys(routerMapState).find( - (key) => routerMapState[key] === currentPath, - ); - - // Handle chat routes - if (!matchingKey && currentPath.startsWith('/chat/')) { - const chatIndex = currentPath.split('/').pop(); - if (!isNaN(chatIndex)) { - matchingKey = 'chat' + chatIndex; - } else { - matchingKey = 'chat'; - } - } - - // If we found a matching key, update the selected keys - if (matchingKey) { - setSelectedKeys([matchingKey]); - } - }, [location.pathname, routerMapState]); - - useEffect(() => { - setIsCollapsed(styleState.siderCollapsed); - }, [styleState.siderCollapsed]); - - // Custom divider style - const dividerStyle = { - margin: '8px 0', - opacity: 0.6, - }; - - // Custom group label style - const groupLabelStyle = { - padding: '8px 16px', - color: 'var(--semi-color-text-2)', - fontSize: '12px', - fontWeight: 'bold', - textTransform: 'uppercase', - letterSpacing: '0.5px', - }; - - return ( - <> - - - ); -}; - -export default SiderBar; diff --git a/web/src/components/TaskLogsTable.js b/web/src/components/TaskLogsTable.js deleted file mode 100644 index 4d243133..00000000 --- a/web/src/components/TaskLogsTable.js +++ /dev/null @@ -1,512 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Label } from 'semantic-ui-react'; -import { - API, - copy, - isAdmin, - showError, - showSuccess, - timestamp2string, -} from '../helpers'; - -import { - Table, - Tag, - Form, - Button, - Layout, - Modal, - Typography, - Progress, - Card, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; - -const colors = [ - 'amber', - 'blue', - 'cyan', - 'green', - 'grey', - 'indigo', - 'light-blue', - 'lime', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'violet', - 'yellow', -]; - -const renderTimestamp = (timestampInSeconds) => { - const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒 - - const year = date.getFullYear(); // 获取年份 - const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数 - const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数 - const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数 - const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数 - const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数 - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出 -}; - -function renderDuration(submit_time, finishTime) { - // 确保startTime和finishTime都是有效的时间戳 - if (!submit_time || !finishTime) return 'N/A'; - - // 将时间戳转换为Date对象 - const start = new Date(submit_time); - const finish = new Date(finishTime); - - // 计算时间差(毫秒) - const durationMs = finish - start; - - // 将时间差转换为秒,并保留一位小数 - const durationSec = (durationMs / 1000).toFixed(1); - - // 设置颜色:大于60秒则为红色,小于等于60秒则为绿色 - const color = durationSec > 60 ? 'red' : 'green'; - - // 返回带有样式的颜色标签 - return ( - - {durationSec} 秒 - - ); -} - -const LogsTable = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalContent, setModalContent] = useState(''); - const isAdminUser = isAdmin(); - const columns = [ - { - title: '提交时间', - dataIndex: 'submit_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - title: '结束时间', - dataIndex: 'finish_time', - render: (text, record, index) => { - return
{text ? renderTimestamp(text) : '-'}
; - }, - }, - { - title: '进度', - dataIndex: 'progress', - width: 50, - render: (text, record, index) => { - return ( -
- { - // 转换例如100%为数字100,如果text未定义,返回0 - isNaN(text.replace('%', '')) ? ( - text - ) : ( - - ) - } -
- ); - }, - }, - { - title: '花费时间', - dataIndex: 'finish_time', // 以finish_time作为dataIndex - key: 'finish_time', - render: (finish, record) => { - // 假设record.start_time是存在的,并且finish是完成时间的时间戳 - return <>{finish ? renderDuration(record.submit_time, finish) : '-'}; - }, - }, - { - title: '渠道', - dataIndex: 'channel_id', - className: isAdminUser ? 'tableShow' : 'tableHiddle', - render: (text, record, index) => { - return ( -
- { - copyText(text); // 假设copyText是用于文本复制的函数 - }} - > - {' '} - {text}{' '} - -
- ); - }, - }, - { - title: '平台', - dataIndex: 'platform', - render: (text, record, index) => { - return
{renderPlatform(text)}
; - }, - }, - { - title: '类型', - dataIndex: 'action', - render: (text, record, index) => { - return
{renderType(text)}
; - }, - }, - { - title: '任务ID(点击查看详情)', - dataIndex: 'task_id', - render: (text, record, index) => { - return ( - { - setModalContent(JSON.stringify(record, null, 2)); - setIsModalOpen(true); - }} - > -
{text}
-
- ); - }, - }, - { - title: '任务状态', - dataIndex: 'status', - render: (text, record, index) => { - return
{renderStatus(text)}
; - }, - }, - - { - title: '失败原因', - dataIndex: 'fail_reason', - render: (text, record, index) => { - // 如果text未定义,返回替代文本,例如空字符串''或其他 - if (!text) { - return '无'; - } - - return ( - { - setModalContent(text); - setIsModalOpen(true); - }} - > - {text} - - ); - }, - }, - ]; - - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [logCount, setLogCount] = useState(ITEMS_PER_PAGE); - const [logType] = useState(0); - - let now = new Date(); - // 初始化start_timestamp为前一天 - let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const [inputs, setInputs] = useState({ - channel_id: '', - task_id: '', - start_timestamp: timestamp2string(zeroNow.getTime() / 1000), - end_timestamp: '', - }); - const { channel_id, task_id, start_timestamp, end_timestamp } = inputs; - - const handleInputChange = (value, name) => { - setInputs((inputs) => ({ ...inputs, [name]: value })); - }; - - const setLogsFormat = (logs) => { - for (let i = 0; i < logs.length; i++) { - logs[i].timestamp2string = timestamp2string(logs[i].created_at); - logs[i].key = '' + logs[i].id; - } - // data.key = '' + data.id - setLogs(logs); - setLogCount(logs.length + ITEMS_PER_PAGE); - // console.log(logCount); - }; - - const loadLogs = async (startIdx) => { - setLoading(true); - - let url = ''; - let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000); - let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000); - if (isAdminUser) { - url = `/api/task/?p=${startIdx}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } else { - url = `/api/task/self?p=${startIdx}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`; - } - const res = await API.get(url); - let { success, message, data } = res.data; - if (success) { - if (startIdx === 0) { - setLogsFormat(data); - } else { - let newLogs = [...logs]; - newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data); - setLogsFormat(newLogs); - } - } else { - showError(message); - } - setLoading(false); - }; - - const pageData = logs.slice( - (activePage - 1) * ITEMS_PER_PAGE, - activePage * ITEMS_PER_PAGE, - ); - - const handlePageChange = (page) => { - setActivePage(page); - if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) { - // In this case we have to load more data and then append them. - loadLogs(page - 1).then((r) => {}); - } - }; - - const refresh = async () => { - // setLoading(true); - setActivePage(1); - await loadLogs(0); - }; - - const copyText = async (text) => { - if (await copy(text)) { - showSuccess('已复制:' + text); - } else { - // setSearchKeyword(text); - Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text }); - } - }; - - useEffect(() => { - refresh().then(); - }, [logType]); - - const renderType = (type) => { - switch (type) { - case 'MUSIC': - return ( - - ); - case 'LYRICS': - return ( - - ); - - default: - return ( - - ); - } - }; - - const renderPlatform = (type) => { - switch (type) { - case 'suno': - return ( - - ); - default: - return ( - - ); - } - }; - - const renderStatus = (type) => { - switch (type) { - case 'SUCCESS': - return ( - - ); - case 'NOT_START': - return ( - - ); - case 'SUBMITTED': - return ( - - ); - case 'IN_PROGRESS': - return ( - - ); - case 'FAILURE': - return ( - - ); - case 'QUEUED': - return ( - - ); - case 'UNKNOWN': - return ( - - ); - case '': - return ( - - ); - default: - return ( - - ); - } - }; - - return ( - <> - -
- <> - {isAdminUser && ( - handleInputChange(value, 'channel_id')} - /> - )} - handleInputChange(value, 'task_id')} - /> - - handleInputChange(value, 'start_timestamp')} - /> - handleInputChange(value, 'end_timestamp')} - /> - - - - - - - setIsModalOpen(false)} - onCancel={() => setIsModalOpen(false)} - closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式 - width={800} // 设置模态框宽度 - > -

{modalContent}

-
- - - ); -}; - -export default LogsTable; diff --git a/web/src/components/UsersTable.js b/web/src/components/UsersTable.js deleted file mode 100644 index b77f7396..00000000 --- a/web/src/components/UsersTable.js +++ /dev/null @@ -1,515 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { API, showError, showSuccess } from '../helpers'; -import { - Button, - Form, - Popconfirm, - Space, - Table, - Tag, - Tooltip, -} from '@douyinfe/semi-ui'; -import { ITEMS_PER_PAGE } from '../constants'; -import { renderGroup, renderNumber, renderQuota } from '../helpers/render'; -import AddUser from '../pages/User/AddUser'; -import EditUser from '../pages/User/EditUser'; -import { useTranslation } from 'react-i18next'; - -const UsersTable = () => { - const { t } = useTranslation(); - - function renderRole(role) { - switch (role) { - case 1: - return {t('普通用户')}; - case 10: - return ( - - {t('管理员')} - - ); - case 100: - return ( - - {t('超级管理员')} - - ); - default: - return ( - - {t('未知身份')} - - ); - } - } - const columns = [ - { - title: 'ID', - dataIndex: 'id', - }, - { - title: t('用户名'), - dataIndex: 'username', - }, - { - title: t('分组'), - dataIndex: 'group', - render: (text, record, index) => { - return
{renderGroup(text)}
; - }, - }, - { - title: t('统计信息'), - dataIndex: 'info', - render: (text, record, index) => { - return ( -
- - - - {renderQuota(record.quota)} - - - - - {renderQuota(record.used_quota)} - - - - - {renderNumber(record.request_count)} - - - -
- ); - }, - }, - { - title: t('邀请信息'), - dataIndex: 'invite', - render: (text, record, index) => { - return ( -
- - - - {renderNumber(record.aff_count)} - - - - - {renderQuota(record.aff_history_quota)} - - - - {record.inviter_id === 0 ? ( - - {t('无')} - - ) : ( - - {record.inviter_id} - - )} - - -
- ); - }, - }, - { - title: t('角色'), - dataIndex: 'role', - render: (text, record, index) => { - return
{renderRole(text)}
; - }, - }, - { - title: t('状态'), - dataIndex: 'status', - render: (text, record, index) => { - return ( -
- {record.DeletedAt !== null ? ( - {t('已注销')} - ) : ( - renderStatus(text) - )} -
- ); - }, - }, - { - title: '', - dataIndex: 'operate', - render: (text, record, index) => ( -
- {record.DeletedAt !== null ? ( - <> - ) : ( - <> - { - manageUser(record.id, 'promote', record); - }} - > - - - { - manageUser(record.id, 'demote', record); - }} - > - - - {record.status === 1 ? ( - - ) : ( - - )} - - { - manageUser(record.id, 'delete', record).then(() => { - removeRecord(record.id); - }); - }} - > - - - - )} -
- ), - }, - ]; - - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [activePage, setActivePage] = useState(1); - const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE); - const [searchKeyword, setSearchKeyword] = useState(''); - const [searching, setSearching] = useState(false); - const [searchGroup, setSearchGroup] = useState(''); - const [groupOptions, setGroupOptions] = useState([]); - const [userCount, setUserCount] = useState(ITEMS_PER_PAGE); - const [showAddUser, setShowAddUser] = useState(false); - const [showEditUser, setShowEditUser] = useState(false); - const [editingUser, setEditingUser] = useState({ - id: undefined, - }); - - const removeRecord = (key) => { - let newDataSource = [...users]; - if (key != null) { - let idx = newDataSource.findIndex((data) => data.id === key); - - if (idx > -1) { - // update deletedAt - newDataSource[idx].DeletedAt = new Date(); - setUsers(newDataSource); - } - } - }; - - const setUserFormat = (users) => { - for (let i = 0; i < users.length; i++) { - users[i].key = users[i].id; - } - setUsers(users); - }; - - const loadUsers = async (startIdx, pageSize) => { - const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setUserCount(data.total); - setUserFormat(newPageData); - } else { - showError(message); - } - setLoading(false); - }; - - useEffect(() => { - loadUsers(0, pageSize) - .then() - .catch((reason) => { - showError(reason); - }); - fetchGroups().then(); - }, []); - - const manageUser = async (userId, action, record) => { - const res = await API.post('/api/user/manage', { - id: userId, - action, - }); - const { success, message } = res.data; - if (success) { - showSuccess('操作成功完成!'); - let user = res.data.data; - let newUsers = [...users]; - if (action === 'delete') { - } else { - record.status = user.status; - record.role = user.role; - } - setUsers(newUsers); - } else { - showError(message); - } - }; - - const renderStatus = (status) => { - switch (status) { - case 1: - return {t('已激活')}; - case 2: - return ( - - {t('已封禁')} - - ); - default: - return ( - - {t('未知状态')} - - ); - } - }; - - const searchUsers = async ( - startIdx, - pageSize, - searchKeyword, - searchGroup, - ) => { - if (searchKeyword === '' && searchGroup === '') { - // if keyword is blank, load files instead. - await loadUsers(startIdx, pageSize); - return; - } - setSearching(true); - const res = await API.get( - `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`, - ); - const { success, message, data } = res.data; - if (success) { - const newPageData = data.items; - setActivePage(data.page); - setUserCount(data.total); - setUserFormat(newPageData); - } else { - showError(message); - } - setSearching(false); - }; - - const handleKeywordChange = async (value) => { - setSearchKeyword(value.trim()); - }; - - const handlePageChange = (page) => { - setActivePage(page); - if (searchKeyword === '' && searchGroup === '') { - loadUsers(page, pageSize).then(); - } else { - searchUsers(page, pageSize, searchKeyword, searchGroup).then(); - } - }; - - const closeAddUser = () => { - setShowAddUser(false); - }; - - const closeEditUser = () => { - setShowEditUser(false); - setEditingUser({ - id: undefined, - }); - }; - - const refresh = async () => { - setActivePage(1); - if (searchKeyword === '') { - await loadUsers(activePage, pageSize); - } else { - await searchUsers(activePage, pageSize, searchKeyword, searchGroup); - } - }; - - const fetchGroups = async () => { - try { - let res = await API.get(`/api/group/`); - // add 'all' option - // res.data.data.unshift('all'); - if (res === undefined) { - return; - } - setGroupOptions( - res.data.data.map((group) => ({ - label: group, - value: group, - })), - ); - } catch (error) { - showError(error.message); - } - }; - - const handlePageSizeChange = async (size) => { - localStorage.setItem('page-size', size + ''); - setPageSize(size); - setActivePage(1); - loadUsers(activePage, size) - .then() - .catch((reason) => { - showError(reason); - }); - }; - - return ( - <> - - - { - searchUsers(activePage, pageSize, searchKeyword, searchGroup); - }} - labelPosition='left' - > -
- - - handleKeywordChange(value)} - /> - - - { - setSearchGroup(value); - searchUsers(activePage, pageSize, searchKeyword, value); - }} - /> - - - -
- - -
- t('第 {{start}} - {{end}} 条,共 {{total}} 条', { - start: page.currentStart, - end: page.currentEnd, - total: users.length, - }), - currentPage: activePage, - pageSize: pageSize, - total: userCount, - pageSizeOpts: [10, 20, 50, 100], - showSizeChanger: true, - onPageSizeChange: (size) => { - handlePageSizeChange(size); - }, - onPageChange: handlePageChange, - }} - loading={loading} - /> - - ); -}; - -export default UsersTable; diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js new file mode 100644 index 00000000..c8847a33 --- /dev/null +++ b/web/src/components/auth/LoginForm.js @@ -0,0 +1,524 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onGitHubOAuthClicked, + onOIDCClicked, + onLinuxDOOAuthClicked +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import TelegramLoginButton from 'react-telegram-login'; + +import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import { useTranslation } from 'react-i18next'; + +const LoginForm = () => { + const [inputs, setInputs] = useState({ + username: '', + password: '', + wechat_verification_code: '', + }); + const [searchParams, setSearchParams] = useSearchParams(); + const [submitted, setSubmitted] = useState(false); + const { username, password } = inputs; + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + let navigate = useNavigate(); + const [status, setStatus] = useState({}); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailLogin, setShowEmailLogin] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailLoginLoading, setEmailLoginLoading] = useState(false); + const [loginLoading, setLoginLoading] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const { t } = useTranslation(); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + useEffect(() => { + if (searchParams.get('expired')) { + showError(t('未登录或登录已过期,请重新登录')); + } + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + setStatus(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setSubmitted(true); + setLoginLoading(true); + try { + if (username && password) { + const res = await API.post( + `/api/user/login?turnstile=${turnstileToken}`, + { + username, + password, + }, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + Modal.error({ + title: '您正在使用默认密码!', + content: '请立刻修改默认密码!', + centered: true, + }); + } + navigate('/console'); + } else { + showError(message); + } + } else { + showError('请输入用户名和密码!'); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setLoginLoading(false); + } + } + + // 添加Telegram登录处理函数 + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + // 包装的GitHub登录点击处理 + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + // 包装的OIDC登录点击处理 + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + // 包装的LinuxDO登录点击处理 + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + // 包装的邮箱登录选项点击处理 + const handleEmailLoginClick = () => { + setEmailLoginLoading(true); + setShowEmailLogin(true); + setEmailLoginLoading(false); + }; + + // 包装的重置密码点击处理 + const handleResetPasswordClick = () => { + setResetPasswordLoading(true); + navigate('/reset'); + setResetPasswordLoading(false); + }; + + // 包装的其他登录选项点击处理 + const handleOtherLoginOptionsClick = () => { + setOtherLoginOptionsLoading(true); + setShowEmailLogin(false); + setOtherLoginOptionsLoading(false); + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ {t('没有账户?')} {t('注册')} +
+
+
+
+
+ ); + }; + + const renderEmailLoginForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + +
+ + + +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ {t('没有账户?')} {t('注册')} +
+
+
+
+
+ ); + }; + + // 微信登录模态框 + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+
+ {showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailLoginForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default LoginForm; diff --git a/web/src/components/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js similarity index 58% rename from web/src/components/OAuth2Callback.js rename to web/src/components/auth/OAuth2Callback.js index 616ec313..6d0bbe70 100644 --- a/web/src/components/OAuth2Callback.js +++ b/web/src/components/auth/OAuth2Callback.js @@ -1,16 +1,16 @@ import React, { useContext, useEffect, useState } from 'react'; -import { Dimmer, Loader, Segment } from 'semantic-ui-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { API, showError, showSuccess, updateAPI } from '../helpers'; -import { UserContext } from '../context/User'; -import { setUserData } from '../helpers/data.js'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; +import { UserContext } from '../../context/User'; +import Loading from '../common/Loading'; const OAuth2Callback = (props) => { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const [userState, userDispatch] = useContext(UserContext); - const [prompt, setPrompt] = useState('处理中...'); - const [processing, setProcessing] = useState(true); + const [prompt, setPrompt] = useState(t('处理中...')); let navigate = useNavigate(); @@ -21,25 +21,25 @@ const OAuth2Callback = (props) => { const { success, message, data } = res.data; if (success) { if (message === 'bind') { - showSuccess('绑定成功!'); - navigate('/setting'); + showSuccess(t('绑定成功!')); + navigate('/console/setting'); } else { userDispatch({ type: 'login', payload: data }); localStorage.setItem('user', JSON.stringify(data)); setUserData(data); updateAPI(); - showSuccess('登录成功!'); - navigate('/token'); + showSuccess(t('登录成功!')); + navigate('/console/token'); } } else { showError(message); if (count === 0) { - setPrompt(`操作失败,重定向至登录界面中...`); - navigate('/setting'); // in case this is failed to bind GitHub + setPrompt(t('操作失败,重定向至登录界面中...')); + navigate('/console/setting'); // in case this is failed to bind GitHub return; } count++; - setPrompt(`出现错误,第 ${count} 次重试中...`); + setPrompt(t('出现错误,第 ${count} 次重试中...', { count })); await new Promise((resolve) => setTimeout(resolve, count * 2000)); await sendCode(code, state, count); } @@ -51,13 +51,7 @@ const OAuth2Callback = (props) => { sendCode(code, state, 0).then(); }, []); - return ( - - - {prompt} - - - ); + return ; }; export default OAuth2Callback; diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js new file mode 100644 index 00000000..e2d9a9ad --- /dev/null +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react'; +import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers'; +import { useSearchParams, Link } from 'react-router-dom'; +import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; +import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetConfirm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + token: '', + }); + const { email, token } = inputs; + const isValidResetLink = email && token; + + const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [newPassword, setNewPassword] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const [formApi, setFormApi] = useState(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let token = searchParams.get('token'); + let email = searchParams.get('email'); + setInputs({ + token: token || '', + email: email || '', + }); + if (formApi) { + formApi.setValues({ + email: email || '', + newPassword: newPassword || '' + }); + } + }, [searchParams, newPassword, formApi]); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + async function handleSubmit(e) { + if (!email || !token) { + showError(t('无效的重置链接,请重新发起密码重置请求')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.post(`/api/user/reset`, { + email, + token, + }); + const { success, message } = res.data; + if (success) { + let password = res.data.data; + setNewPassword(password); + await copy(password); + showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置确认')} +
+
+ {!isValidResetLink && ( + + )} +
setFormApi(api)} + initValues={{ email: email || '', newPassword: newPassword || '' }} + className="space-y-4" + > + } + placeholder={email ? '' : t('等待获取邮箱信息...')} + /> + + {newPassword && ( + } + suffix={ + + } + /> + )} + +
+ +
+ + +
+ {t('返回登录')} +
+
+
+
+
+
+
+ ); +}; + +export default PasswordResetConfirm; diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js new file mode 100644 index 00000000..29c3d477 --- /dev/null +++ b/web/src/components/auth/PasswordResetForm.js @@ -0,0 +1,147 @@ +import React, { useEffect, useState } from 'react'; +import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; +import { IconMail } from '@douyinfe/semi-icons'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + }); + const { email } = inputs; + + const [loading, setLoading] = useState(false); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + function handleChange(value) { + setInputs((inputs) => ({ ...inputs, email: value })); + } + + async function handleSubmit(e) { + if (!email) { + showError(t('请输入邮箱地址')); + return; + } + if (turnstileEnabled && turnstileToken === '') { + showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.get( + `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('重置邮件发送成功,请检查邮箱!')); + setInputs({ ...inputs, email: '' }); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置')} +
+
+
+ } + /> + +
+ +
+ + +
+ {t('想起来了?')} {t('登录')} +
+
+
+ + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+
+
+ ); +}; + +export default PasswordResetForm; diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js new file mode 100644 index 00000000..0d9c8982 --- /dev/null +++ b/web/src/components/auth/RegisterForm.js @@ -0,0 +1,566 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons'; +import { + onGitHubOAuthClicked, + onLinuxDOOAuthClicked, + onOIDCClicked, +} from '../../helpers/index.js'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import TelegramLoginButton from 'react-telegram-login/src'; +import { UserContext } from '../../context/User/index.js'; +import { useTranslation } from 'react-i18next'; + +const RegisterForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + password2: '', + email: '', + verification_code: '', + wechat_verification_code: '', + }); + const { username, password, password2 } = inputs; + const [showEmailVerification, setShowEmailVerification] = useState(false); + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [loading, setLoading] = useState(false); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailRegister, setShowEmailRegister] = useState(false); + const [status, setStatus] = useState({}); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); + const [registerLoading, setRegisterLoading] = useState(false); + const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); + const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + let navigate = useNavigate(); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + setStatus(status); + setShowEmailVerification(status.email_verification); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (password.length < 8) { + showInfo('密码长度不得小于 8 位!'); + return; + } + if (password !== password2) { + showInfo('两次输入的密码不一致'); + return; + } + if (username && password) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setRegisterLoading(true); + try { + if (!affCode) { + affCode = localStorage.getItem('aff'); + } + inputs.aff_code = affCode; + const res = await API.post( + `/api/user/register?turnstile=${turnstileToken}`, + inputs, + ); + const { success, message } = res.data; + if (success) { + navigate('/login'); + showSuccess('注册成功!'); + } else { + showError(message); + } + } catch (error) { + showError('注册失败,请重试'); + } finally { + setRegisterLoading(false); + } + } + } + + const sendVerificationCode = async () => { + if (inputs.email === '') return; + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setVerificationCodeLoading(true); + try { + const res = await API.get( + `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess('验证码发送成功,请检查你的邮箱!'); + } else { + showError(message); + } + } catch (error) { + showError('发送验证码失败,请重试'); + } finally { + setVerificationCodeLoading(false); + } + }; + + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + const handleEmailRegisterClick = () => { + setEmailRegisterLoading(true); + setShowEmailRegister(true); + setEmailRegisterLoading(false); + }; + + const handleOtherRegisterOptionsClick = () => { + setOtherRegisterOptionsLoading(true); + setShowEmailRegister(false); + setOtherRegisterOptionsLoading(false); + }; + + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderEmailRegisterForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + + handleChange('password2', value)} + prefix={} + /> + + {showEmailVerification && ( + <> + handleChange('email', value)} + prefix={} + suffix={ + + } + /> + handleChange('verification_code', value)} + prefix={} + /> + + )} + +
+ +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+
+ {showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailRegisterForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default RegisterForm; diff --git a/web/src/components/common/Loading.js b/web/src/components/common/Loading.js new file mode 100644 index 00000000..a12be053 --- /dev/null +++ b/web/src/components/common/Loading.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Spin } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; + +const Loading = ({ prompt: name = '', size = 'large' }) => { + const { t } = useTranslation(); + + return ( +
+
+ + + {name ? t('{{name}}', { name }) : t('加载中...')} + +
+
+ ); +}; + +export default Loading; diff --git a/web/src/components/LinuxDoIcon.js b/web/src/components/common/logo/LinuxDoIcon.js similarity index 100% rename from web/src/components/LinuxDoIcon.js rename to web/src/components/common/logo/LinuxDoIcon.js diff --git a/web/src/components/OIDCIcon.js b/web/src/components/common/logo/OIDCIcon.js similarity index 97% rename from web/src/components/OIDCIcon.js rename to web/src/components/common/logo/OIDCIcon.js index eec3e655..bd98c8fb 100644 --- a/web/src/components/OIDCIcon.js +++ b/web/src/components/common/logo/OIDCIcon.js @@ -11,8 +11,8 @@ const OIDCIcon = (props) => { version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='10969' - width='1em' - height='1em' + width='20' + height='20' > { version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='5091' - width='16' - height='16' + width='20' + height='20' > { + if (props.code && ref.current) { + mermaid + .run({ + nodes: [ref.current], + suppressErrors: true, + }) + .catch((e) => { + setHasError(true); + console.error('[Mermaid] ', e.message); + }); + } + }, [props.code]); + + function viewSvgInNewWindow() { + const svg = ref.current?.querySelector('svg'); + if (!svg) return; + const text = new XMLSerializer().serializeToString(svg); + const blob = new Blob([text], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + } + + if (hasError) { + return null; + } + + return ( +
viewSvgInNewWindow()} + > + {props.code} +
+ ); +} + +export function PreCode(props) { + const ref = useRef(null); + const [mermaidCode, setMermaidCode] = useState(''); + const [htmlCode, setHtmlCode] = useState(''); + const { t } = useTranslation(); + + const renderArtifacts = useDebouncedCallback(() => { + if (!ref.current) return; + const mermaidDom = ref.current.querySelector('code.language-mermaid'); + if (mermaidDom) { + setMermaidCode(mermaidDom.innerText); + } + const htmlDom = ref.current.querySelector('code.language-html'); + const refText = ref.current.querySelector('code')?.innerText; + if (htmlDom) { + setHtmlCode(htmlDom.innerText); + } else if ( + refText?.startsWith(' { + if (ref.current) { + const codeElements = ref.current.querySelectorAll('code'); + const wrapLanguages = [ + '', + 'md', + 'markdown', + 'text', + 'txt', + 'plaintext', + 'tex', + 'latex', + ]; + codeElements.forEach((codeElement) => { + let languageClass = codeElement.className.match(/language-(\w+)/); + let name = languageClass ? languageClass[1] : ''; + if (wrapLanguages.includes(name)) { + codeElement.style.whiteSpace = 'pre-wrap'; + } + }); + setTimeout(renderArtifacts, 1); + } + }, []); + + return ( + <> +
+        
+ +
+ {props.children} +
+ {mermaidCode.length > 0 && ( + + )} + {htmlCode.length > 0 && ( +
+
+ HTML预览: +
+
+
+ )} + + ); +} + +function CustomCode(props) { + const ref = useRef(null); + const [collapsed, setCollapsed] = useState(true); + const [showToggle, setShowToggle] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + if (ref.current) { + const codeHeight = ref.current.scrollHeight; + setShowToggle(codeHeight > 400); + ref.current.scrollTop = ref.current.scrollHeight; + } + }, [props.children]); + + const toggleCollapsed = () => { + setCollapsed((collapsed) => !collapsed); + }; + + const renderShowMoreButton = () => { + if (showToggle && collapsed) { + return ( +
+ +
+ ); + } + return null; + }; + + return ( +
+ + {props.children} + + {renderShowMoreButton()} +
+ ); +} + +function escapeBrackets(text) { + const pattern = + /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; + return text.replace( + pattern, + (match, codeBlock, squareBracket, roundBracket) => { + if (codeBlock) { + return codeBlock; + } else if (squareBracket) { + return `$$${squareBracket}$$`; + } else if (roundBracket) { + return `$${roundBracket}$`; + } + return match; + }, + ); +} + +function tryWrapHtmlCode(text) { + // 尝试包装HTML代码 + if (text.includes('```')) { + return text; + } + return text + .replace( + /([`]*?)(\w*?)([\n\r]*?)()/g, + (match, quoteStart, lang, newLine, doctype) => { + return !quoteStart ? '\n```html\n' + doctype : match; + }, + ) + .replace( + /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g, + (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { + return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match; + }, + ); +} + +function _MarkdownContent(props) { + const { + content, + className, + animated = false, + previousContentLength = 0, + } = props; + + const escapedContent = useMemo(() => { + return tryWrapHtmlCode(escapeBrackets(content)); + }, [content]); + + // 判断是否为用户消息 + const isUserMessage = className && className.includes('user-message'); + + const rehypePluginsBase = useMemo(() => { + const base = [ + RehypeKatex, + [ + RehypeHighlight, + { + detect: false, + ignoreMissing: true, + }, + ], + ]; + if (animated) { + base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]); + } + return base; + }, [animated, previousContentLength]); + + return ( +

, + a: (aProps) => { + const href = aProps.href || ''; + if (/\.(aac|mp3|opus|wav)$/.test(href)) { + return ( +

+ +
+ ); + } + if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { + return ( + + ); + } + const isInternal = /^\/#/i.test(href); + const target = isInternal ? '_self' : aProps.target ?? '_blank'; + return ( + { + e.target.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.target.style.textDecoration = 'none'; + }} + /> + ); + }, + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + h4: (props) =>

, + h5: (props) =>

, + h6: (props) =>
, + blockquote: (props) => ( +
+ ), + ul: (props) =>
    , + ol: (props) =>
      , + li: (props) =>
    1. , + table: (props) => ( +
      +
+ + ), + th: (props) => ( +
+ ), + td: (props) => ( + + ), + }} + > + {escapedContent} + + ); +} + +export const MarkdownContent = React.memo(_MarkdownContent); + +export function MarkdownRenderer(props) { + const { + content, + loading, + fontSize = 14, + fontFamily = 'inherit', + className, + style, + animated = false, + previousContentLength = 0, + ...otherProps + } = props; + + return ( +
+ {loading ? ( +
+
+ 正在渲染... +
+ ) : ( + + )} +
+ ); +} + +export default MarkdownRenderer; \ No newline at end of file diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css new file mode 100644 index 00000000..3b5c1067 --- /dev/null +++ b/web/src/components/common/markdown/markdown.css @@ -0,0 +1,444 @@ +/* 基础markdown样式 */ +.markdown-body { + font-family: inherit; + line-height: 1.6; + color: var(--semi-color-text-0); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +/* 用户消息样式 - 白色字体适配蓝色背景 */ +.user-message { + color: white !important; +} + +.user-message .markdown-body { + color: white !important; +} + +.user-message h1, +.user-message h2, +.user-message h3, +.user-message h4, +.user-message h5, +.user-message h6 { + color: white !important; +} + +.user-message p { + color: white !important; +} + +.user-message span { + color: white !important; +} + +.user-message div { + color: white !important; +} + +.user-message li { + color: white !important; +} + +.user-message td, +.user-message th { + color: white !important; +} + +.user-message blockquote { + color: white !important; + border-left-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.user-message code:not(pre code) { + color: #000 !important; + background-color: rgba(255, 255, 255, 0.9) !important; +} + +.user-message a { + color: #87CEEB !important; + /* 浅蓝色链接 */ +} + +.user-message a:hover { + color: #B0E0E6 !important; + /* hover时更浅的蓝色 */ +} + +/* 表格在用户消息中的样式 */ +.user-message table { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message th { + background-color: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message td { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +/* 加载动画 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* 代码高亮主题 - 适配Semi Design */ +.hljs { + display: block; + overflow-x: auto; + padding: 0; + background: transparent; + color: var(--semi-color-text-0); +} + +.hljs-comment, +.hljs-quote { + color: var(--semi-color-text-2); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: var(--semi-color-warning); +} + +.hljs-string, +.hljs-doctag { + color: var(--semi-color-success); +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--semi-color-info); + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: var(--semi-color-primary); + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: var(--semi-color-tertiary); +} + +.hljs-symbol, +.hljs-bullet { + color: var(--semi-color-warning); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--semi-color-info); +} + +.hljs-meta { + color: var(--semi-color-text-2); +} + +.hljs-deletion { + background: var(--semi-color-danger-light-default); +} + +.hljs-addition { + background: var(--semi-color-success-light-default); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +/* Mermaid容器样式 */ +.mermaid-container { + transition: all 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +/* 代码块样式增强 */ +pre { + position: relative; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + transition: all 0.2s ease; +} + +pre:hover { + border-color: var(--semi-color-primary) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +pre:hover .copy-code-button { + opacity: 1 !important; +} + +.copy-code-button { + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.copy-code-button:hover { + opacity: 1 !important; +} + +.copy-code-button button { + pointer-events: auto !important; + cursor: pointer !important; +} + +/* 确保按钮可点击 */ +.copy-code-button .semi-button { + pointer-events: auto !important; + cursor: pointer !important; + transition: all 0.2s ease; +} + +.copy-code-button .semi-button:hover { + background-color: var(--semi-color-fill-1) !important; + border-color: var(--semi-color-primary) !important; + transform: scale(1.05); +} + +/* 表格响应式 */ +@media (max-width: 768px) { + .markdown-body table { + font-size: 12px; + } + + .markdown-body th, + .markdown-body td { + padding: 6px 8px; + } +} + +/* 数学公式样式 */ +.katex { + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + text-align: center; +} + +/* 链接hover效果 */ +.markdown-body a { + transition: all 0.2s ease; +} + +/* 引用块样式增强 */ +.markdown-body blockquote { + position: relative; +} + +.markdown-body blockquote::before { + content: '"'; + position: absolute; + left: -8px; + top: -8px; + font-size: 24px; + color: var(--semi-color-primary); + opacity: 0.3; +} + +/* 列表样式增强 */ +.markdown-body ul li::marker { + color: var(--semi-color-primary); +} + +.markdown-body ol li::marker { + color: var(--semi-color-primary); + font-weight: bold; +} + +/* 分隔线样式 */ +.markdown-body hr { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, var(--semi-color-border), transparent); + margin: 24px 0; +} + +/* 图片样式 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 12px 0; +} + +/* 内联代码样式 */ +.markdown-body code:not(pre code) { + background-color: var(--semi-color-fill-1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: var(--semi-color-primary); + border: 1px solid var(--semi-color-border); +} + +/* 标题锚点样式 */ +.markdown-body h1:hover, +.markdown-body h2:hover, +.markdown-body h3:hover, +.markdown-body h4:hover, +.markdown-body h5:hover, +.markdown-body h6:hover { + position: relative; +} + +/* 任务列表样式 */ +.markdown-body input[type="checkbox"] { + margin-right: 8px; + transform: scale(1.1); +} + +.markdown-body li.task-list-item { + list-style: none; + margin-left: -20px; +} + +/* 键盘按键样式 */ +.markdown-body kbd { + background-color: var(--semi-color-fill-0); + border: 1px solid var(--semi-color-border); + border-radius: 3px; + box-shadow: 0 1px 0 var(--semi-color-border); + color: var(--semi-color-text-0); + display: inline-block; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +/* 详情折叠样式 */ +.markdown-body details { + border: 1px solid var(--semi-color-border); + border-radius: 6px; + padding: 12px; + margin: 12px 0; +} + +.markdown-body summary { + cursor: pointer; + font-weight: bold; + color: var(--semi-color-primary); + margin-bottom: 8px; +} + +.markdown-body summary:hover { + color: var(--semi-color-primary-hover); +} + +/* 脚注样式 */ +.markdown-body .footnote-ref { + color: var(--semi-color-primary); + text-decoration: none; + font-weight: bold; +} + +.markdown-body .footnote-ref:hover { + text-decoration: underline; +} + +/* 警告块样式 */ +.markdown-body .warning { + background-color: var(--semi-color-warning-light-default); + border-left: 4px solid var(--semi-color-warning); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .info { + background-color: var(--semi-color-info-light-default); + border-left: 4px solid var(--semi-color-info); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .success { + background-color: var(--semi-color-success-light-default); + border-left: 4px solid var(--semi-color-success); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .danger { + background-color: var(--semi-color-danger-light-default); + border-left: 4px solid var(--semi-color-danger); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(6px) scale(0.98); + filter: blur(3px); + } + 60% { + opacity: 0.85; + filter: blur(0.5px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.animate-fade-in { + animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: opacity, transform; +} \ No newline at end of file diff --git a/web/src/components/custom/TextInput.js b/web/src/components/custom/TextInput.js deleted file mode 100644 index 8c137fe1..00000000 --- a/web/src/components/custom/TextInput.js +++ /dev/null @@ -1,28 +0,0 @@ -import { Input, Typography } from '@douyinfe/semi-ui'; -import React from 'react'; - -const TextInput = ({ - label, - name, - value, - onChange, - placeholder, - type = 'text', -}) => { - return ( - <> -
- {label} -
- onChange(value)} - value={value} - autoComplete='new-password' - /> - - ); -}; - -export default TextInput; diff --git a/web/src/components/custom/TextNumberInput.js b/web/src/components/custom/TextNumberInput.js deleted file mode 100644 index 36e0cac0..00000000 --- a/web/src/components/custom/TextNumberInput.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Input, InputNumber, Typography } from '@douyinfe/semi-ui'; -import React from 'react'; - -const TextNumberInput = ({ label, name, value, onChange, placeholder }) => { - return ( - <> -
- {label} -
- onChange(value)} - value={value} - autoComplete='new-password' - /> - - ); -}; - -export default TextNumberInput; diff --git a/web/src/components/fetchTokenKeys.js b/web/src/components/fetchTokenKeys.js deleted file mode 100644 index e9cec001..00000000 --- a/web/src/components/fetchTokenKeys.js +++ /dev/null @@ -1,68 +0,0 @@ -// src/hooks/useTokenKeys.js -import { useEffect, useState } from 'react'; -import { API, showError } from '../helpers'; - -async function fetchTokenKeys() { - try { - const response = await API.get('/api/token/?p=0&size=100'); - const { success, data } = response.data; - if (success) { - const activeTokens = data.filter((token) => token.status === 1); - return activeTokens.map((token) => token.key); - } else { - throw new Error('Failed to fetch token keys'); - } - } catch (error) { - console.error('Error fetching token keys:', error); - return []; - } -} - -function getServerAddress() { - let status = localStorage.getItem('status'); - let serverAddress = ''; - - if (status) { - try { - status = JSON.parse(status); - serverAddress = status.server_address || ''; - } catch (error) { - console.error('Failed to parse status from localStorage:', error); - } - } - - if (!serverAddress) { - serverAddress = window.location.origin; - } - - return serverAddress; -} - -export function useTokenKeys(id) { - const [keys, setKeys] = useState([]); - // const [chatLink, setChatLink] = useState(''); - const [serverAddress, setServerAddress] = useState(''); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const loadAllData = async () => { - const fetchedKeys = await fetchTokenKeys(); - if (fetchedKeys.length === 0) { - showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!'); - setTimeout(() => { - window.location.href = '/token'; - }, 1500); // 延迟 1.5 秒后跳转 - } - setKeys(fetchedKeys); - setIsLoading(false); - // setChatLink(link); - - const address = getServerAddress(); - setServerAddress(address); - }; - - loadAllData(); - }, []); - - return { keys, serverAddress, isLoading }; -} diff --git a/web/src/components/layout/Footer.js b/web/src/components/layout/Footer.js new file mode 100644 index 00000000..4f44c1dc --- /dev/null +++ b/web/src/components/layout/Footer.js @@ -0,0 +1,112 @@ +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@douyinfe/semi-ui'; +import { getFooterHTML, getLogo, getSystemName } from '../../helpers'; +import { StatusContext } from '../../context/Status'; + +const FooterBar = () => { + const { t } = useTranslation(); + const [footer, setFooter] = useState(getFooterHTML()); + const systemName = getSystemName(); + const logo = getLogo(); + const [statusState] = useContext(StatusContext); + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const loadFooter = () => { + let footer_html = localStorage.getItem('footer_html'); + if (footer_html) { + setFooter(footer_html); + } + }; + + const currentYear = new Date().getFullYear(); + + const customFooter = useMemo(() => ( + + ), [logo, systemName, t, currentYear, isDemoSiteMode]); + + useEffect(() => { + loadFooter(); + }, []); + + return ( +
+ {footer ? ( +
+ ) : ( + customFooter + )} +
+ ); +}; + +export default FooterBar; diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js new file mode 100644 index 00000000..6317c576 --- /dev/null +++ b/web/src/components/layout/HeaderBar.js @@ -0,0 +1,536 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { useSetTheme, useTheme } from '../../context/Theme/index.js'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js'; +import fireworks from 'react-fireworks'; +import { CN, GB } from 'country-flag-icons/react/3x2'; +import NoticeModal from './NoticeModal.js'; + +import { + IconClose, + IconMenu, + IconLanguage, + IconChevronDown, + IconSun, + IconMoon, + IconExit, + IconUserSetting, + IconCreditCard, + IconKey, + IconBell, +} from '@douyinfe/semi-icons'; +import { + Avatar, + Button, + Dropdown, + Tag, + Typography, + Skeleton, +} from '@douyinfe/semi-ui'; +import { StatusContext } from '../../context/Status/index.js'; +import { useStyle, styleActions } from '../../context/Style/index.js'; + +const HeaderBar = () => { + const { t, i18n } = useTranslation(); + const [userState, userDispatch] = useContext(UserContext); + const [statusState, statusDispatch] = useContext(StatusContext); + const { state: styleState, dispatch: styleDispatch } = useStyle(); + const [isLoading, setIsLoading] = useState(true); + let navigate = useNavigate(); + const [currentLang, setCurrentLang] = useState(i18n.language); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const location = useLocation(); + const [noticeVisible, setNoticeVisible] = useState(false); + + const systemName = getSystemName(); + const logo = getLogo(); + const currentDate = new Date(); + const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false; + const docsLink = statusState?.status?.docs_link || ''; + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const theme = useTheme(); + const setTheme = useSetTheme(); + + const mainNavLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('定价'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; + + async function logout() { + await API.get('/api/user/logout'); + showSuccess(t('注销成功!')); + userDispatch({ type: 'logout' }); + localStorage.removeItem('user'); + navigate('/login'); + setMobileMenuOpen(false); + } + + const handleNewYearClick = () => { + fireworks.init('root', {}); + fireworks.start(); + setTimeout(() => { + fireworks.stop(); + }, 3000); + }; + + useEffect(() => { + if (theme === 'dark') { + document.body.setAttribute('theme-mode', 'dark'); + document.documentElement.classList.add('dark'); + } else { + document.body.removeAttribute('theme-mode'); + document.documentElement.classList.remove('dark'); + } + + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ themeMode: theme }, '*'); + } + + }, [theme, isNewYear]); + + useEffect(() => { + const handleLanguageChanged = (lng) => { + setCurrentLang(lng); + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ lang: lng }, '*'); + } + }; + + i18n.on('languageChanged', handleLanguageChanged); + return () => { + i18n.off('languageChanged', handleLanguageChanged); + }; + }, [i18n]); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 500); + return () => clearTimeout(timer); + }, []); + + const handleLanguageChange = (lang) => { + i18n.changeLanguage(lang); + setMobileMenuOpen(false); + }; + + const handleNavLinkClick = (itemKey) => { + if (itemKey === 'home') { + styleDispatch(styleActions.setSider(false)); + } + setMobileMenuOpen(false); + }; + + const renderNavLinks = (isMobileView = false, isLoading = false) => { + if (isLoading) { + const skeletonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full rounded-md' + : 'flex items-center gap-1 p-2 rounded-md'; + return Array(4) + .fill(null) + .map((_, index) => ( +
+ +
+ )); + } + + return mainNavLinks.map((link) => { + const commonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold' + : 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold'; + + const linkContent = ( + {link.text} + ); + + if (link.isExternal) { + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + } + + let targetPath = link.to; + if (link.itemKey === 'console' && !userState.user) { + targetPath = '/login'; + } + + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + }); + }; + + const renderUserArea = () => { + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (userState.user) { + return ( + + { + navigate('/console/personal'); + setMobileMenuOpen(false); + }} + 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" + > +
+ + {t('个人设置')} +
+
+ { + navigate('/console/token'); + setMobileMenuOpen(false); + }} + 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" + > +
+ + {t('API令牌')} +
+
+ { + navigate('/console/topup'); + setMobileMenuOpen(false); + }} + 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" + > +
+ + {t('钱包')} +
+
+ +
+ + {t('退出')} +
+
+ + } + > + +
+ ); + } else { + const showRegisterButton = !isSelfUseMode; + + const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5"; + + const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors"; + let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; + + let registerButtonClasses = `${commonSizingAndLayoutClass}`; + + const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5"; + const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5"; + + if (showRegisterButton) { + if (styleState.isMobile) { + loginButtonClasses += " !rounded-full"; + } else { + loginButtonClasses += " !rounded-l-full !rounded-r-none"; + } + registerButtonClasses += " !rounded-r-full !rounded-l-none"; + } else { + loginButtonClasses += " !rounded-full"; + } + + return ( +
+ handleNavLinkClick('login')} className="flex"> + + + {showRegisterButton && ( +
+ handleNavLinkClick('register')} className="flex -ml-px"> + + +
+ )} +
+ ); + } + }; + + // 检查当前路由是否以/console开头 + const isConsoleRoute = location.pathname.startsWith('/console'); + + return ( +
+ setNoticeVisible(false)} + isMobile={styleState.isMobile} + /> +
+
+
+
+
+ handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2"> + {isLoading ? ( + + ) : ( + logo + )} +
+
+ {isLoading ? ( + + ) : ( + + {systemName} + + )} + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + + )} +
+
+ + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( +
+ + {isSelfUseMode ? t('自用模式') : t('演示站点')} + +
+ )} + + +
+ +
+ {isNewYear && ( + + + Happy New Year!!! 🎉 + + + } + > +
+
+
+ +
+
+ +
+
+
+ ); +}; + +export default HeaderBar; diff --git a/web/src/components/layout/NoticeModal.js b/web/src/components/layout/NoticeModal.js new file mode 100644 index 00000000..9bb062a1 --- /dev/null +++ b/web/src/components/layout/NoticeModal.js @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Modal, Empty } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; +import { API, showError } from '../../helpers'; +import { marked } from 'marked'; +import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations'; + +const NoticeModal = ({ visible, onClose, isMobile }) => { + const { t } = useTranslation(); + const [noticeContent, setNoticeContent] = useState(''); + const [loading, setLoading] = useState(false); + + const handleCloseTodayNotice = () => { + const today = new Date().toDateString(); + localStorage.setItem('notice_close_date', today); + onClose(); + }; + + const displayNotice = async () => { + setLoading(true); + try { + const res = await API.get('/api/notice'); + const { success, message, data } = res.data; + if (success) { + if (data !== '') { + const htmlNotice = marked.parse(data); + setNoticeContent(htmlNotice); + } else { + setNoticeContent(''); + } + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + displayNotice(); + } + }, [visible]); + + const renderContent = () => { + if (loading) { + return
; + } + + if (!noticeContent) { + return ( +
+ } + darkModeImage={} + description={t('暂无公告')} + /> +
+ ); + } + + return ( +
+ ); + }; + + return ( + + + +
+ )} + size={isMobile ? 'full-width' : 'large'} + > + {renderContent()} + + ); +}; + +export default NoticeModal; \ No newline at end of file diff --git a/web/src/components/PageLayout.js b/web/src/components/layout/PageLayout.js similarity index 69% rename from web/src/components/PageLayout.js rename to web/src/components/layout/PageLayout.js index d52bc0d4..e25901ef 100644 --- a/web/src/components/PageLayout.js +++ b/web/src/components/layout/PageLayout.js @@ -1,23 +1,30 @@ import HeaderBar from './HeaderBar.js'; import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar.js'; -import App from '../App.js'; +import App from '../../App.js'; import FooterBar from './Footer.js'; import { ToastContainer } from 'react-toastify'; import React, { useContext, useEffect } from 'react'; -import { StyleContext } from '../context/Style/index.js'; +import { useStyle } from '../../context/Style/index.js'; import { useTranslation } from 'react-i18next'; -import { API, getLogo, getSystemName, showError } from '../helpers/index.js'; -import { setStatusData } from '../helpers/data.js'; -import { UserContext } from '../context/User/index.js'; -import { StatusContext } from '../context/Status/index.js'; +import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js'; +import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; +import { useLocation } from 'react-router-dom'; const { Sider, Content, Header, Footer } = Layout; const PageLayout = () => { const [userState, userDispatch] = useContext(UserContext); const [statusState, statusDispatch] = useContext(StatusContext); - const [styleState, styleDispatch] = useContext(StyleContext); + const { state: styleState } = useStyle(); const { i18n } = useTranslation(); + const location = useLocation(); + + const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat'); + + const shouldInnerPadding = location.pathname.includes('/console') && + !location.pathname.startsWith('/console/chat') && + location.pathname !== '/console/playground'; const loadUser = () => { let user = localStorage.getItem('user'); @@ -61,15 +68,8 @@ const PageLayout = () => { if (savedLang) { i18n.changeLanguage(savedLang); } - - // 默认显示侧边栏 - styleDispatch({ type: 'SET_SIDER', payload: true }); }, [i18n]); - // 获取侧边栏折叠状态 - const isSidebarCollapsed = - localStorage.getItem('default_collapse_sidebar') === 'true'; - return ( { padding: 0, height: 'auto', lineHeight: 'normal', - position: styleState.isMobile ? 'sticky' : 'fixed', + position: 'fixed', width: '100%', top: 0, zIndex: 100, - boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)', }} > { style={{ position: 'fixed', left: 0, - top: '56px', + top: '64px', zIndex: 99, - background: 'var(--semi-color-bg-1)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', border: 'none', paddingRight: '0', - height: 'calc(100vh - 56px)', + height: 'calc(100vh - 64px)', }} > @@ -126,7 +123,7 @@ const PageLayout = () => { : styleState.showSider ? styleState.siderCollapsed ? '60px' - : '200px' + : '180px' : '0', transition: 'margin-left 0.3s ease', flex: '1 1 auto', @@ -137,23 +134,24 @@ const PageLayout = () => { - - - + {!shouldHideFooter && ( + + + + )} diff --git a/web/src/components/SetupCheck.js b/web/src/components/layout/SetupCheck.js similarity index 90% rename from web/src/components/SetupCheck.js rename to web/src/components/layout/SetupCheck.js index 99364b00..3fbd9012 100644 --- a/web/src/components/SetupCheck.js +++ b/web/src/components/layout/SetupCheck.js @@ -1,6 +1,6 @@ import React, { useContext, useEffect } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; -import { StatusContext } from '../context/Status'; +import { StatusContext } from '../../context/Status'; const SetupCheck = ({ children }) => { const [statusState] = useContext(StatusContext); diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js new file mode 100644 index 00000000..8ede4bc8 --- /dev/null +++ b/web/src/components/layout/SiderBar.js @@ -0,0 +1,448 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; +import { ChevronLeft } from 'lucide-react'; +import { useStyle, styleActions } from '../../context/Style/index.js'; +import { + isAdmin, + isRoot, + showError +} from '../../helpers/index.js'; + +import { + Nav, + Divider, + Tooltip, +} from '@douyinfe/semi-ui'; + +const routerMap = { + home: '/', + channel: '/console/channel', + token: '/console/token', + redemption: '/console/redemption', + topup: '/console/topup', + user: '/console/user', + log: '/console/log', + midjourney: '/console/midjourney', + setting: '/console/setting', + about: '/about', + detail: '/console', + pricing: '/pricing', + task: '/console/task', + playground: '/console/playground', + personal: '/console/personal', +}; + +const SiderBar = () => { + const { t } = useTranslation(); + const { state: styleState, dispatch: styleDispatch } = useStyle(); + + const [selectedKeys, setSelectedKeys] = useState(['home']); + const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed); + const [chatItems, setChatItems] = useState([]); + const [openedKeys, setOpenedKeys] = useState([]); + const location = useLocation(); + const [routerMapState, setRouterMapState] = useState(routerMap); + + const workspaceItems = useMemo( + () => [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('API令牌'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ], + [ + localStorage.getItem('enable_data_export'), + localStorage.getItem('enable_drawing'), + localStorage.getItem('enable_task'), + t, + ], + ); + + const financeItems = useMemo( + () => [ + { + text: t('钱包'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ], + [t], + ); + + const adminItems = useMemo( + () => [ + { + text: t('渠道'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ], + [isAdmin(), isRoot(), t], + ); + + const chatMenuItems = useMemo( + () => [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ], + [chatItems, t], + ); + + // 更新路由映射,添加聊天路由 + const updateRouterMapWithChats = (chats) => { + const newRouterMap = { ...routerMap }; + + if (Array.isArray(chats) && chats.length > 0) { + for (let i = 0; i < chats.length; i++) { + newRouterMap['chat' + i] = '/console/chat/' + i; + } + } + + setRouterMapState(newRouterMap); + return newRouterMap; + }; + + // 加载聊天项 + useEffect(() => { + let chats = localStorage.getItem('chats'); + if (chats) { + try { + chats = JSON.parse(chats); + if (Array.isArray(chats)) { + let chatItems = []; + for (let i = 0; i < chats.length; i++) { + let chat = {}; + for (let key in chats[i]) { + chat.text = key; + chat.itemKey = 'chat' + i; + chat.to = '/console/chat/' + i; + } + chatItems.push(chat); + } + setChatItems(chatItems); + updateRouterMapWithChats(chats); + } + } catch (e) { + console.error(e); + showError('聊天数据解析失败'); + } + } + }, []); + + // 根据当前路径设置选中的菜单项 + useEffect(() => { + const currentPath = location.pathname; + let matchingKey = Object.keys(routerMapState).find( + (key) => routerMapState[key] === currentPath, + ); + + // 处理聊天路由 + if (!matchingKey && currentPath.startsWith('/console/chat/')) { + const chatIndex = currentPath.split('/').pop(); + if (!isNaN(chatIndex)) { + matchingKey = 'chat' + chatIndex; + } else { + matchingKey = 'chat'; + } + } + + // 如果找到匹配的键,更新选中的键 + if (matchingKey) { + setSelectedKeys([matchingKey]); + } + }, [location.pathname, routerMapState]); + + // 同步折叠状态 + useEffect(() => { + setIsCollapsed(styleState.siderCollapsed); + }, [styleState.siderCollapsed]); + + // 获取菜单项对应的颜色 + const getItemColor = (itemKey) => { + switch (itemKey) { + case 'detail': return sidebarIconColors.dashboard; + case 'playground': return sidebarIconColors.terminal; + case 'chat': return sidebarIconColors.message; + case 'token': return sidebarIconColors.key; + case 'log': return sidebarIconColors.chart; + case 'midjourney': return sidebarIconColors.image; + case 'task': return sidebarIconColors.check; + case 'topup': return sidebarIconColors.credit; + case 'channel': return sidebarIconColors.layers; + case 'redemption': return sidebarIconColors.gift; + case 'user': + case 'personal': return sidebarIconColors.user; + case 'setting': return sidebarIconColors.settings; + default: + // 处理聊天项 + if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; + return 'currentColor'; + } + }; + + // 渲染自定义菜单项 + const renderNavItem = (item) => { + // 跳过隐藏的项目 + if (item.className === 'tableHiddle') return null; + + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + +
+ } + icon={ +
+ {getLucideIcon(item.itemKey, isSelected)} +
+ } + className={item.className} + /> + ); + }; + + // 渲染子菜单项 + const renderSubItem = (item) => { + if (item.items && item.items.length > 0) { + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + + + } + icon={ +
+ {getLucideIcon(item.itemKey, isSelected)} +
+ } + > + {item.items.map((subItem) => { + const isSubSelected = selectedKeys.includes(subItem.itemKey); + const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit'; + + return ( + + {subItem.text} + + } + /> + ); + })} +
+ ); + } else { + return renderNavItem(item); + } + }; + + return ( +
+ + + {/* 底部折叠按钮 */} +
{ + const newCollapsed = !isCollapsed; + setIsCollapsed(newCollapsed); + styleDispatch(styleActions.setSiderCollapsed(newCollapsed)); + }} + > + +
+ + + +
+
+
+
+ ); +}; + +export default SiderBar; diff --git a/web/src/components/playground/ChatArea.js b/web/src/components/playground/ChatArea.js new file mode 100644 index 00000000..81e2df90 --- /dev/null +++ b/web/src/components/playground/ChatArea.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Card, + Chat, + Typography, + Button, +} from '@douyinfe/semi-ui'; +import { + MessageSquare, + Eye, + EyeOff, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import CustomInputRender from './CustomInputRender'; + +const ChatArea = ({ + chatRef, + message, + inputs, + styleState, + showDebugPanel, + roleInfo, + onMessageSend, + onMessageCopy, + onMessageReset, + onMessageDelete, + onStopGenerator, + onClearMessages, + onToggleDebugPanel, + renderCustomChatContent, + renderChatBoxAction, +}) => { + const { t } = useTranslation(); + + const renderInputArea = React.useCallback((props) => { + return ; + }, []); + + return ( + + {/* 聊天头部 */} + {styleState.isMobile ? ( +
+ ) : ( +
+
+
+
+ +
+
+ + {t('AI 对话')} + + + {inputs.model || t('选择模型开始对话')} + +
+
+
+ +
+
+
+ )} + + {/* 聊天内容区域 */} +
+ null, + }} + renderInputArea={renderInputArea} + roleConfig={roleInfo} + style={{ + height: '100%', + maxWidth: '100%', + overflow: 'hidden' + }} + chats={message} + onMessageSend={onMessageSend} + onMessageCopy={onMessageCopy} + onMessageReset={onMessageReset} + onMessageDelete={onMessageDelete} + showClearContext + showStopGenerate + onStopGenerator={onStopGenerator} + onClear={onClearMessages} + className="h-full" + placeholder={t('请输入您的问题...')} + /> +
+
+ ); +}; + +export default ChatArea; \ No newline at end of file diff --git a/web/src/components/playground/CodeViewer.js b/web/src/components/playground/CodeViewer.js new file mode 100644 index 00000000..1ce723ce --- /dev/null +++ b/web/src/components/playground/CodeViewer.js @@ -0,0 +1,313 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; +import { Copy, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { copy } from '../../helpers'; + +const PERFORMANCE_CONFIG = { + MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数 + PREVIEW_LENGTH: 5000, // 预览长度 + VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数 +}; + +const codeThemeStyles = { + container: { + backgroundColor: '#1e1e1e', + color: '#d4d4d4', + fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace', + fontSize: '13px', + lineHeight: '1.4', + borderRadius: '8px', + border: '1px solid #3c3c3c', + position: 'relative', + overflow: 'hidden', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + }, + content: { + height: '100%', + overflowY: 'auto', + overflowX: 'auto', + padding: '16px', + margin: 0, + whiteSpace: 'pre', + wordBreak: 'normal', + background: '#1e1e1e', + }, + actionButton: { + position: 'absolute', + zIndex: 10, + backgroundColor: 'rgba(45, 45, 45, 0.9)', + border: '1px solid rgba(255, 255, 255, 0.1)', + color: '#d4d4d4', + borderRadius: '6px', + transition: 'all 0.2s ease', + }, + actionButtonHover: { + backgroundColor: 'rgba(60, 60, 60, 0.95)', + borderColor: 'rgba(255, 255, 255, 0.2)', + transform: 'scale(1.05)', + }, + noContent: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: '#666', + fontSize: '14px', + fontStyle: 'italic', + backgroundColor: 'var(--semi-color-fill-0)', + borderRadius: '8px', + }, + performanceWarning: { + padding: '8px 12px', + backgroundColor: 'rgba(255, 193, 7, 0.1)', + border: '1px solid rgba(255, 193, 7, 0.3)', + borderRadius: '6px', + color: '#ffc107', + fontSize: '12px', + marginBottom: '8px', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, +}; + +const highlightJson = (str) => { + return str.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + (match) => { + let color = '#b5cea8'; + if (/^"/.test(match)) { + color = /:$/.test(match) ? '#9cdcfe' : '#ce9178'; + } else if (/true|false|null/.test(match)) { + color = '#569cd6'; + } + return `${match}`; + } + ); +}; + +const isJsonLike = (content, language) => { + if (language === 'json') return true; + const trimmed = content.trim(); + return (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); +}; + +const formatContent = (content) => { + if (!content) return ''; + + if (typeof content === 'object') { + try { + return JSON.stringify(content, null, 2); + } catch (e) { + return String(content); + } + } + + if (typeof content === 'string') { + try { + const parsed = JSON.parse(content); + return JSON.stringify(parsed, null, 2); + } catch (e) { + return content; + } + } + + return String(content); +}; + +const CodeViewer = ({ content, title, language = 'json' }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [isHoveringCopy, setIsHoveringCopy] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + const formattedContent = useMemo(() => formatContent(content), [content]); + + const contentMetrics = useMemo(() => { + const length = formattedContent.length; + const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH; + const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER; + return { length, isLarge, isVeryLarge }; + }, [formattedContent.length]); + + const displayContent = useMemo(() => { + if (!contentMetrics.isLarge || isExpanded) { + return formattedContent; + } + return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + + '\n\n// ... 内容被截断以提升性能 ...'; + }, [formattedContent, contentMetrics.isLarge, isExpanded]); + + const highlightedContent = useMemo(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + return displayContent; + } + + if (isJsonLike(displayContent, language)) { + return highlightJson(displayContent); + } + + return displayContent; + }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]); + + const handleCopy = useCallback(async () => { + try { + const textToCopy = typeof content === 'object' && content !== null + ? JSON.stringify(content, null, 2) + : content; + + const success = await copy(textToCopy); + setCopied(true); + Toast.success(t('已复制到剪贴板')); + setTimeout(() => setCopied(false), 2000); + + if (!success) { + throw new Error('Copy operation failed'); + } + } catch (err) { + Toast.error(t('复制失败')); + console.error('Copy failed:', err); + } + }, [content, t]); + + const handleToggleExpand = useCallback(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + setIsProcessing(true); + setTimeout(() => { + setIsExpanded(true); + setIsProcessing(false); + }, 100); + } else { + setIsExpanded(!isExpanded); + } + }, [isExpanded, contentMetrics.isVeryLarge]); + + if (!content) { + const placeholderText = { + preview: t('正在构造请求体预览...'), + request: t('暂无请求数据'), + response: t('暂无响应数据') + }[title] || t('暂无数据'); + + return ( +
+ {placeholderText} +
+ ); + } + + const warningTop = contentMetrics.isLarge ? '52px' : '12px'; + const contentPadding = contentMetrics.isLarge ? '52px' : '16px'; + + return ( +
+ {/* 性能警告 */} + {contentMetrics.isLarge && ( +
+ + + {contentMetrics.isVeryLarge + ? t('内容较大,已启用性能优化模式') + : t('内容较大,部分功能可能受限')} + +
+ )} + + {/* 复制按钮 */} +
setIsHoveringCopy(true)} + onMouseLeave={() => setIsHoveringCopy(false)} + > + +
+ + {/* 代码内容 */} +
+ {isProcessing ? ( +
+
+ {t('正在处理大内容...')} +
+ ) : ( +
+ )} +
+ + {/* 展开/收起按钮 */} + {contentMetrics.isLarge && !isProcessing && ( +
+ + + +
+ )} +
+ ); +}; + +export default CodeViewer; \ No newline at end of file diff --git a/web/src/components/playground/ConfigManager.js b/web/src/components/playground/ConfigManager.js new file mode 100644 index 00000000..ddff8785 --- /dev/null +++ b/web/src/components/playground/ConfigManager.js @@ -0,0 +1,260 @@ +import React, { useRef } from 'react'; +import { + Button, + Typography, + Toast, + Modal, + Dropdown, +} from '@douyinfe/semi-ui'; +import { + Download, + Upload, + RotateCcw, + Settings2, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage'; + +const ConfigManager = ({ + currentConfig, + onConfigImport, + onConfigReset, + styleState, + messages, +}) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + + const handleExport = () => { + try { + // 在导出前先保存当前配置,确保导出的是最新内容 + const configWithTimestamp = { + ...currentConfig, + timestamp: new Date().toISOString(), + }; + localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp)); + + exportConfig(currentConfig, messages); + Toast.success({ + content: t('配置已导出到下载文件夹'), + duration: 3, + }); + } catch (error) { + Toast.error({ + content: t('导出配置失败: ') + error.message, + duration: 3, + }); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const importedConfig = await importConfig(file); + + Modal.confirm({ + title: t('确认导入配置'), + content: t('导入的配置将覆盖当前设置,是否继续?'), + okText: t('确定导入'), + cancelText: t('取消'), + onOk: () => { + onConfigImport(importedConfig); + Toast.success({ + content: t('配置导入成功'), + duration: 3, + }); + }, + }); + } catch (error) { + Toast.error({ + content: t('导入配置失败: ') + error.message, + duration: 3, + }); + } finally { + // 重置文件输入,允许重复选择同一文件 + event.target.value = ''; + } + }; + + const handleReset = () => { + Modal.confirm({ + title: t('重置配置'), + content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'), + okText: t('确定重置'), + cancelText: t('取消'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + // 询问是否同时重置消息 + Modal.confirm({ + title: t('重置选项'), + content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'), + okText: t('同时重置消息'), + cancelText: t('仅重置配置'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + clearConfig(); + onConfigReset({ resetMessages: true }); + Toast.success({ + content: t('配置和消息已全部重置'), + duration: 3, + }); + }, + onCancel: () => { + clearConfig(); + onConfigReset({ resetMessages: false }); + Toast.success({ + content: t('配置已重置,对话消息已保留'), + duration: 3, + }); + }, + }); + }, + }); + }; + + const getConfigStatus = () => { + if (hasStoredConfig()) { + const timestamp = getConfigTimestamp(); + if (timestamp) { + const date = new Date(timestamp); + return t('上次保存: ') + date.toLocaleString(); + } + return t('已有保存的配置'); + } + return t('暂无保存的配置'); + }; + + const dropdownItems = [ + { + node: 'item', + name: 'export', + onClick: handleExport, + children: ( +
+ + {t('导出配置')} +
+ ), + }, + { + node: 'item', + name: 'import', + onClick: handleImportClick, + children: ( +
+ + {t('导入配置')} +
+ ), + }, + { + node: 'divider', + }, + { + node: 'item', + name: 'reset', + onClick: handleReset, + children: ( +
+ + {t('重置配置')} +
+ ), + }, + ]; + + if (styleState.isMobile) { + // 移动端显示简化的下拉菜单 + return ( + <> + +
+ + {/* 导出和导入按钮 */} +
+ + + +
+ + +
+ ); +}; + +export default ConfigManager; \ No newline at end of file diff --git a/web/src/components/playground/CustomInputRender.js b/web/src/components/playground/CustomInputRender.js new file mode 100644 index 00000000..ff62c104 --- /dev/null +++ b/web/src/components/playground/CustomInputRender.js @@ -0,0 +1,58 @@ +import React from 'react'; + +const CustomInputRender = (props) => { + const { detailProps } = props; + const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps; + + // 清空按钮 + const styledClearNode = clearContextNode + ? React.cloneElement(clearContextNode, { + className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`, + style: { + ...clearContextNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }) + : null; + + // 发送按钮 + const styledSendNode = React.cloneElement(sendNode, { + className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`, + style: { + ...sendNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }); + + return ( +
+
+ {/* 清空对话按钮 - 左边 */} + {styledClearNode} +
+ {inputNode} +
+ {/* 发送按钮 - 右边 */} + {styledSendNode} +
+
+ ); +}; + +export default CustomInputRender; \ No newline at end of file diff --git a/web/src/components/playground/CustomRequestEditor.js b/web/src/components/playground/CustomRequestEditor.js new file mode 100644 index 00000000..9b11b4f4 --- /dev/null +++ b/web/src/components/playground/CustomRequestEditor.js @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { + TextArea, + Typography, + Button, + Switch, + Banner, +} from '@douyinfe/semi-ui'; +import { + Code, + Edit, + Check, + X, + AlertTriangle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +const CustomRequestEditor = ({ + customRequestMode, + customRequestBody, + onCustomRequestModeChange, + onCustomRequestBodyChange, + defaultPayload, +}) => { + const { t } = useTranslation(); + const [isValid, setIsValid] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [localValue, setLocalValue] = useState(customRequestBody || ''); + + // 当切换到自定义模式时,用默认payload初始化 + useEffect(() => { + if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) { + const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : ''; + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]); + + // 同步外部传入的customRequestBody到本地状态 + useEffect(() => { + if (customRequestBody !== localValue) { + setLocalValue(customRequestBody || ''); + validateJson(customRequestBody || ''); + } + }, [customRequestBody]); + + // 验证JSON格式 + const validateJson = (value) => { + if (!value.trim()) { + setIsValid(true); + setErrorMessage(''); + return true; + } + + try { + JSON.parse(value); + setIsValid(true); + setErrorMessage(''); + return true; + } catch (error) { + setIsValid(false); + setErrorMessage(`JSON格式错误: ${error.message}`); + return false; + } + }; + + const handleValueChange = (value) => { + setLocalValue(value); + validateJson(value); + // 始终保存用户输入,让预览逻辑处理JSON解析错误 + onCustomRequestBodyChange(value); + }; + + const handleModeToggle = (enabled) => { + onCustomRequestModeChange(enabled); + if (enabled && defaultPayload) { + const defaultJson = JSON.stringify(defaultPayload, null, 2); + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }; + + const formatJson = () => { + try { + const parsed = JSON.parse(localValue); + const formatted = JSON.stringify(parsed, null, 2); + setLocalValue(formatted); + onCustomRequestBodyChange(formatted); + setIsValid(true); + setErrorMessage(''); + } catch (error) { + // 如果格式化失败,保持原样 + } + }; + + return ( +
+ {/* 自定义模式开关 */} +
+
+ + + 自定义请求体模式 + +
+ +
+ + {customRequestMode && ( + <> + {/* 提示信息 */} + } + className="!rounded-lg" + closable={false} + /> + + {/* JSON编辑器 */} +
+
+ + 请求体 JSON + +
+ {isValid ? ( +
+ + + 格式正确 + +
+ ) : ( +
+ + + 格式错误 + +
+ )} + +
+
+ +