Merge branch 'main' into feat-04

This commit is contained in:
neotf
2025-06-11 13:55:47 +08:00
committed by GitHub
177 changed files with 25105 additions and 11236 deletions

View File

@@ -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 }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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, "通道测试完成", "所有通道测试已完成")
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

169
controller/uptime_kuma.go Normal file
View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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"`

6
go.mod
View File

@@ -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

8
go.sum
View File

@@ -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=

View File

@@ -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..."

View File

@@ -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)

41
middleware/stats.go Normal file
View File

@@ -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),
}
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -8,6 +8,7 @@ var ModelList = []string{
"qwq-32b",
"qwen3-235b-a22b",
"text-embedding-v1",
"gte-rerank-v2",
}
var ChannelName = "ali"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
}

View File

@@ -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")
}
}
}()
}
}

View File

@@ -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",
}

View File

@@ -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),

View File

@@ -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),
},
}

View File

@@ -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"`
}

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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",
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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: "",
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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())

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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)

327
setting/console.go Normal file
View File

@@ -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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(description), dangerous) {
return fmt.Errorf("第%d个API信息的说明包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(route), dangerous) {
return fmt.Errorf("第%d个API信息的线路描述包含不允许的内容", i+1)
}
}
}
return nil
}
// ValidateApiInfo 保持向后兼容的函数
func ValidateApiInfo(apiInfoStr string) error {
return validateApiInfo(apiInfoStr)
}
// GetApiInfo 获取API信息列表
func GetApiInfo() []map[string]interface{} {
// 从OptionMap中获取API信息如果不存在则返回空数组
common.OptionMapRWMutex.RLock()
apiInfoStr, exists := common.OptionMap["ApiInfo"]
common.OptionMapRWMutex.RUnlock()
if !exists || apiInfoStr == "" {
// 如果没有配置,返回空数组
return []map[string]interface{}{}
}
// 解析存储的API信息
var apiInfo []map[string]interface{}
if err := json.Unmarshal([]byte(apiInfoStr), &apiInfo); err != nil {
// 如果解析失败,返回空数组
return []map[string]interface{}{}
}
return apiInfo
}
// validateAnnouncements 验证系统公告格式
func validateAnnouncements(announcementsStr string) error {
var announcementsList []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcementsList); err != nil {
return fmt.Errorf("系统公告格式错误:%s", err.Error())
}
// 验证数组长度
if len(announcementsList) > 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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个公告的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// validateFAQ 验证常见问答格式
func validateFAQ(faqStr string) error {
var faqList []map[string]interface{}
if err := json.Unmarshal([]byte(faqStr), &faqList); err != nil {
return fmt.Errorf("常见问答格式错误:%s", err.Error())
}
// 验证数组长度
if len(faqList) > 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{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
for _, dangerous := range dangerousChars {
if strings.Contains(strings.ToLower(title), dangerous) {
return fmt.Errorf("第%d个问答的标题包含不允许的内容", i+1)
}
if strings.Contains(strings.ToLower(content), dangerous) {
return fmt.Errorf("第%d个问答的内容包含不允许的内容", i+1)
}
}
}
return nil
}
// GetAnnouncements 获取系统公告列表返回最新的前20条
func GetAnnouncements() []map[string]interface{} {
common.OptionMapRWMutex.RLock()
announcementsStr, exists := common.OptionMap["Announcements"]
common.OptionMapRWMutex.RUnlock()
if !exists || announcementsStr == "" {
return []map[string]interface{}{}
}
var announcements []map[string]interface{}
if err := json.Unmarshal([]byte(announcementsStr), &announcements); err != nil {
return []map[string]interface{}{}
}
// 按发布日期降序排序(最新的在前)
sort.Slice(announcements, func(i, j int) bool {
dateI, okI := announcements[i]["publishDate"].(string)
dateJ, okJ := announcements[j]["publishDate"].(string)
if !okI || !okJ {
return false
}
timeI, errI := time.Parse(time.RFC3339, dateI)
timeJ, errJ := time.Parse(time.RFC3339, dateJ)
if errI != nil || errJ != nil {
return false
}
return timeI.After(timeJ)
})
// 限制返回前20条
if len(announcements) > 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
}

View File

@@ -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
}

Binary file not shown.

View File

@@ -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"
},

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -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() {
}
/>
<Route
path='/channel'
path='/console/channel'
element={
<PrivateRoute>
<Channel />
@@ -63,7 +62,7 @@ function App() {
}
/>
<Route
path='/channel/edit/:id'
path='/console/channel/edit/:id'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel />
@@ -71,7 +70,7 @@ function App() {
}
/>
<Route
path='/channel/add'
path='/console/channel/add'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel />
@@ -79,7 +78,7 @@ function App() {
}
/>
<Route
path='/token'
path='/console/token'
element={
<PrivateRoute>
<Token />
@@ -87,7 +86,7 @@ function App() {
}
/>
<Route
path='/playground'
path='/console/playground'
element={
<PrivateRoute>
<Playground />
@@ -95,7 +94,7 @@ function App() {
}
/>
<Route
path='/redemption'
path='/console/redemption'
element={
<PrivateRoute>
<Redemption />
@@ -103,7 +102,7 @@ function App() {
}
/>
<Route
path='/user'
path='/console/user'
element={
<PrivateRoute>
<User />
@@ -111,7 +110,7 @@ function App() {
}
/>
<Route
path='/user/edit/:id'
path='/console/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser />
@@ -119,7 +118,7 @@ function App() {
}
/>
<Route
path='/user/edit'
path='/console/user/edit'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser />
@@ -138,7 +137,9 @@ function App() {
path='/login'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<LoginForm />
<AuthRedirect>
<LoginForm />
</AuthRedirect>
</Suspense>
}
/>
@@ -146,7 +147,9 @@ function App() {
path='/register'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<RegisterForm />
<AuthRedirect>
<RegisterForm />
</AuthRedirect>
</Suspense>
}
/>
@@ -183,7 +186,7 @@ function App() {
}
/>
<Route
path='/setting'
path='/console/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -193,7 +196,7 @@ function App() {
}
/>
<Route
path='/personal'
path='/console/personal'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -203,7 +206,7 @@ function App() {
}
/>
<Route
path='/topup'
path='/console/topup'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -213,7 +216,7 @@ function App() {
}
/>
<Route
path='/log'
path='/console/log'
element={
<PrivateRoute>
<Log />
@@ -221,7 +224,7 @@ function App() {
}
/>
<Route
path='/detail'
path='/console'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -231,7 +234,7 @@ function App() {
}
/>
<Route
path='/midjourney'
path='/console/midjourney'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -241,7 +244,7 @@ function App() {
}
/>
<Route
path='/task'
path='/console/task'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -267,7 +270,7 @@ function App() {
}
/>
<Route
path='/chat/:id?'
path='/console/chat/:id?'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Chat />

View File

@@ -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 = (
<div className='custom-footer'>
<a
href='https://github.com/Calcium-Ion/new-api'
target='_blank'
rel='noreferrer'
>
New API {import.meta.env.VITE_REACT_APP_VERSION}{' '}
</a>
{t('由')}{' '}
<a href='https://github.com/Calcium-Ion' target='_blank' rel='noreferrer'>
Calcium-Ion
</a>{' '}
{t('开发,基于')}{' '}
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
rel='noreferrer'
>
One API
</a>
</div>
);
useEffect(() => {
const timer = setInterval(() => {
if (remainCheckTimes <= 0) {
clearInterval(timer);
return;
}
remainCheckTimes--;
loadFooter();
}, 200);
return () => clearTimeout(timer);
}, []);
return (
<div
style={{
textAlign: 'center',
paddingBottom: '5px',
}}
>
{footer ? (
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
defaultFooter
)}
</div>
);
};
export default FooterBar;

View File

@@ -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: <IconHome style={headerIconStyle} />,
},
{
text: t('控制台'),
itemKey: 'detail',
to: '/',
icon: <IconTerminal style={headerIconStyle} />,
},
{
text: t('定价'),
itemKey: 'pricing',
to: '/pricing',
icon: <IconPriceTag style={headerIconStyle} />,
},
// Only include the docs button if docsLink exists
...(docsLink
? [
{
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
icon: <IconHelpCircle style={headerIconStyle} />,
},
]
: []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
icon: <IconInfoCircle style={headerIconStyle} />,
},
];
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 (
<>
<Layout>
<div style={{ width: '100%' }}>
<Nav
className={'topnav'}
mode={'horizontal'}
style={headerStyle}
itemStyle={headerItemStyle}
hoverStyle={headerItemHoverStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
const routerMap = {
about: '/about',
login: '/login',
register: '/register',
pricing: '/pricing',
detail: '/detail',
home: '/',
chat: '/chat',
};
return (
<div
onClick={(e) => {
if (props.itemKey === 'home') {
styleDispatch({
type: 'SET_INNER_PADDING',
payload: false,
});
styleDispatch({ type: 'SET_SIDER', payload: false });
} else {
styleDispatch({
type: 'SET_INNER_PADDING',
payload: true,
});
if (!styleState.isMobile) {
styleDispatch({ type: 'SET_SIDER', payload: true });
}
}
}}
>
{props.isExternal ? (
<a
className='header-bar-text'
style={{ textDecoration: 'none' }}
href={props.externalLink}
target='_blank'
rel='noopener noreferrer'
>
{itemElement}
</a>
) : (
<Link
className='header-bar-text'
style={{ textDecoration: 'none' }}
to={routerMap[props.itemKey]}
>
{itemElement}
</Link>
)}
</div>
);
}}
selectedKeys={[]}
// items={headerButtons}
onSelect={(key) => {}}
header={
styleState.isMobile
? {
logo: (
<div
style={{
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
>
{!styleState.showSider ? (
<Button
icon={<IconMenu />}
theme='light'
aria-label={t('展开侧边栏')}
onClick={() =>
styleDispatch({
type: 'SET_SIDER',
payload: true,
})
}
/>
) : (
<Button
icon={<IconIndentLeft />}
theme='light'
aria-label={t('闭侧边栏')}
onClick={() =>
styleDispatch({
type: 'SET_SIDER',
payload: false,
})
}
/>
)}
{(isSelfUseMode || isDemoSiteMode) && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
style={{
position: 'absolute',
top: '-8px',
right: '-15px',
fontSize: '0.7rem',
padding: '0 4px',
height: 'auto',
lineHeight: '1.2',
zIndex: 1,
pointerEvents: 'none',
}}
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
),
}
: {
logo: (
<div style={logoStyle}>
<img src={logo} alt='logo' style={{ height: '28px' }} />
</div>
),
text: (
<div
style={{
position: 'relative',
display: 'inline-block',
}}
>
<span style={systemNameStyle}>{systemName}</span>
{(isSelfUseMode || isDemoSiteMode) && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
style={{
position: 'absolute',
top: '-10px',
right: '-25px',
fontSize: '0.7rem',
padding: '0 4px',
whiteSpace: 'nowrap',
zIndex: 1,
boxShadow: '0 0 3px rgba(255, 255, 255, 0.7)',
}}
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
),
}
}
items={buttons}
footer={
<>
{isNewYear && (
// happy new year
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={handleNewYearClick}>
Happy New Year!!!
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Nav.Item itemKey={'new-year'} text={'🎉'} />
</Dropdown>
)}
{/* <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} /> */}
<>
<Switch
checkedText='🌞'
size={styleState.isMobile ? 'default' : 'large'}
checked={theme === 'dark'}
uncheckedText='🌙'
style={switchStyle}
onChange={(checked) => {
setTheme(checked);
}}
/>
</>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
type={currentLang === 'zh' ? 'primary' : 'tertiary'}
>
中文
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleLanguageChange('en')}
type={currentLang === 'en' ? 'primary' : 'tertiary'}
>
English
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Nav.Item
itemKey={'language'}
icon={<IconLanguage style={headerIconStyle} />}
/>
</Dropdown>
{userState.user ? (
<>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={logout}>
{t('退出')}
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Avatar
size='small'
color={stringToColor(userState.user.username)}
style={avatarStyle}
>
{userState.user.username[0]}
</Avatar>
{styleState.isMobile ? null : (
<Text style={{ marginLeft: '4px', fontWeight: '500' }}>
{userState.user.username}
</Text>
)}
</Dropdown>
</>
) : (
<>
<Nav.Item
itemKey={'login'}
text={!styleState.isMobile ? t('登录') : null}
icon={<IconUser style={headerIconStyle} />}
/>
{
// Hide register option in self-use mode
!styleState.isMobile && !isSelfUseMode && (
<Nav.Item
itemKey={'register'}
text={t('注册')}
icon={<IconKey style={headerIconStyle} />}
/>
)
}
</>
)}
</>
}
></Nav>
</div>
</Layout>
</>
);
};
export default HeaderBar;

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
const Loading = ({ prompt: name = 'page' }) => {
return (
<Spin style={{ height: 100 }} spinning={true}>
加载{name}...
</Spin>
);
};
export default Loading;

View File

@@ -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 (
<div>
<Layout>
<Layout.Header></Layout.Header>
<Layout.Content>
<div
style={{
justifyContent: 'center',
display: 'flex',
marginTop: 120,
}}
>
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
{t('用户登录')}
</Title>
<Form>
<Form.Input
field={'username'}
label={t('用户名/邮箱')}
placeholder={t('用户名/邮箱')}
name='username'
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={t('密码')}
placeholder={t('密码')}
name='password'
type='password'
onChange={(value) => handleChange('password', value)}
/>
<Button
theme='solid'
style={{ width: '100%' }}
type={'primary'}
size='large'
htmlType={'submit'}
onClick={handleSubmit}
>
{t('登录')}
</Button>
</Form>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 20,
}}
>
<Text>
{t('没有账户?')}{' '}
<Link to='/register'>{t('点击注册')}</Link>
</Text>
<Text>
{t('忘记密码?')} <Link to='/reset'>{t('点击重置')}</Link>
</Text>
</div>
{status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.telegram_oauth ||
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
{t('第三方登录')}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
{status.github_oauth ? (
<Button
type='primary'
icon={<IconGithubLogo />}
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
) : (
<></>
)}
{status.oidc_enabled ? (
<Button
type='primary'
icon={<OIDCIcon />}
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
/>
) : (
<></>
)}
{status.linuxdo_oauth ? (
<Button
icon={<LinuxDoIcon />}
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
type='primary'
style={{ color: 'rgba(var(--semi-green-5), 1)' }}
icon={<Icon svg={<WeChatIcon />} />}
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
</div>
{status.telegram_oauth ? (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 5,
}}
>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
</>
) : (
<></>
)}
</>
) : (
<></>
)}
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size={'small'}
centered={true}
>
<div
style={{
display: 'flex',
alignItem: 'center',
flexDirection: 'column',
}}
>
<img src={status.wechat_qrcode} />
</div>
<div style={{ textAlign: 'center' }}>
<p>
{t(
'微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
)}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
</Card>
{turnstileEnabled ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
) : (
<></>
)}
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default LoginForm;

View File

@@ -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 (
<Tag color='blue' size='large'>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large'>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large'>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large'>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large'>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large'>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large'>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large'>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large'>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large'>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large'>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large'>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large'>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large'>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large'>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large'>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large'>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large'>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large'>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type) {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large'>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large'>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
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 (
<Tag color={color} size='large'>
{durationSec} {t('秒')}
</Tag>
);
}
const columns = [
{
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
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 (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
onClick={() => {
copyText(text); // 假设copyText是用于文本复制的函数
}}
>
{' '}
{text}{' '}
</Tag>
</div>
);
},
},
{
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
title: t('任务ID'),
dataIndex: 'mj_id',
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
title: t('提交结果'),
dataIndex: 'code',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderCode(text)}</div>;
},
},
{
title: t('任务状态'),
dataIndex: 'status',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
// 转换例如100%为数字100如果text未定义返回0
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
/>
}
</div>
);
},
},
{
title: t('结果图片'),
dataIndex: 'image_url',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Button
onClick={() => {
setModalImageUrl(text); // 更新图片URL状态
setIsModalOpenurl(true); // 打开模态框
}}
>
{t('查看图片')}
</Button>
);
},
},
{
title: 'Prompt',
dataIndex: 'prompt',
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
title: 'PromptEn',
dataIndex: 'prompt_en',
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
title: t('失败原因'),
dataIndex: 'fail_reason',
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.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 (
<>
<Layout>
{isAdminUser && showBanner ? (
<Banner
type='info'
description={t(
'当前未开启Midjourney回调部分项目可能无法获得绘图结果可在运营设置中开启。',
)}
/>
) : (
<></>
)}
<Form layout='horizontal' style={{ marginTop: 10 }}>
<>
<Form.Input
field='channel_id'
label={t('渠道 ID')}
style={{ width: 176 }}
value={channel_id}
placeholder={t('可选值')}
name='channel_id'
onChange={(value) => handleInputChange(value, 'channel_id')}
/>
<Form.Input
field='mj_id'
label={t('任务 ID')}
style={{ width: 176 }}
value={mj_id}
placeholder={t('可选值')}
name='mj_id'
onChange={(value) => handleInputChange(value, 'mj_id')}
/>
<Form.DatePicker
field='start_timestamp'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
value={start_timestamp}
type='dateTime'
name='start_timestamp'
onChange={(value) => handleInputChange(value, 'start_timestamp')}
/>
<Form.DatePicker
field='end_timestamp'
fluid
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
value={end_timestamp}
type='dateTime'
name='end_timestamp'
onChange={(value) => handleInputChange(value, 'end_timestamp')}
/>
<Form.Section>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
>
{t('查询')}
</Button>
</Form.Section>
</>
</Form>
<Table
style={{ marginTop: 5 }}
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: ITEMS_PER_PAGE,
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
}}
loading={loading}
/>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default LogsTable;

View File

@@ -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 (
<Tag color='teal' size='large'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
}
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',
}}
>
<IconVerify style={{ color: 'green' }} size='large' />
</Popover>
) : 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 (
<>
<Tag
color='green'
size='large'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</>
);
},
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 (
<Space>
{text.map((group) => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
} else {
return (
<Tag
color='blue'
size='large'
onClick={() => {
setSelectedGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
}}
>
{group}
</Tag>
);
}
}
})}
</Space>
);
},
},
{
title: () => (
<span style={{ display: 'flex', alignItems: 'center' }}>
{t('倍率')}
<Popover
content={
<div style={{ padding: 8 }}>
{t('倍率是为了方便换算不同价格的模型')}
<br />
{t('点击查看倍率说明')}
</div>
}
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',
}}
>
<IconHelpCircle
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Popover>
</span>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<>
<Text>
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</Text>
<br />
<Text>
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</Text>
<br />
<Text>
{t('分组倍率')}{groupRatio[selectedGroup]}
</Text>
</>
);
return <div>{content}</div>;
},
},
{
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 = (
<>
<Text>
{t('提示')} ${inputRatioPrice} / 1M tokens
</Text>
<br />
<Text>
{t('补全')} ${completionRatioPrice} / 1M tokens
</Text>
</>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = (
<>
${t('模型价格')}${price}
</>
);
}
return <div>{content}</div>;
},
},
];
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 (
<>
<Layout>
{userState.user ? (
<Banner
type='success'
fullMode={false}
closeIcon='null'
description={t('您的默认分组为:{{group}},分组倍率为:{{ratio}}', {
group: userState.user.group,
ratio: groupRatio[userState.user.group],
})}
/>
) : (
<Banner
type='warning'
fullMode={false}
closeIcon='null'
description={t('您还未登陆,显示的价格为默认分组倍率: {{ratio}}', {
ratio: groupRatio['default'],
})}
/>
)}
<br />
<Banner
type='info'
fullMode={false}
description={
<div>
{t(
'按量计费费用 = 分组倍率 × 模型倍率 × 提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)',
)}
</div>
}
closeIcon='null'
/>
<br />
<Space style={{ marginBottom: 16 }}>
<Input
placeholder={t('模糊搜索模型名称')}
style={{ width: 200 }}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
<Button
theme='light'
type='tertiary'
style={{ width: 150 }}
onClick={() => {
copyText(selectedRowKeys);
}}
disabled={selectedRowKeys == ''}
>
{t('复制选中模型')}
</Button>
</Space>
<Table
style={{ marginTop: 5 }}
columns={columns}
dataSource={models}
loading={loading}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: models.length,
}),
pageSize: models.length,
showSizeChanger: false,
}}
rowSelection={rowSelection}
/>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default ModelPricing;

View File

@@ -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 (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置确认
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
readOnly
/>
{newPassword && (
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='新密码'
name='newPassword'
value={newPassword}
readOnly
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`密码已复制到剪贴板:${newPassword}`);
}}
/>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `密码重置完成` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetConfirm;

View File

@@ -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 (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `重试 (${countdown})` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetForm;

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
import { Navigate } from 'react-router-dom';
import { history } from '../helpers';
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };

View File

@@ -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 (
<Tag color='green' size='large'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: t('ID'),
dataIndex: 'id',
},
{
title: t('名称'),
dataIndex: 'name',
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: t('额度'),
dataIndex: 'quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
},
{
title: t('创建时间'),
dataIndex: 'created_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
render: (text, record, index) => {
return <div>{text === 0 ? t('无') : text}</div>;
},
},
{
title: '',
dataIndex: 'operate',
render: (text, record, index) => (
<div>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
{t('查看')}
</Button>
</Popover>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async (text) => {
await copyText(record.key);
}}
>
{t('复制')}
</Button>
<Popconfirm
title={t('确定是否要删除此兑换码?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
style={{ marginRight: 1 }}
onClick={async () => {
manageRedemption(record.id, 'disable', record);
}}
>
{t('禁用')}
</Button>
) : (
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async () => {
manageRedemption(record.id, 'enable', record);
}}
disabled={record.status === 3}
>
{t('启用')}
</Button>
)}
<Button
theme='light'
type='tertiary'
style={{ marginRight: 1 }}
onClick={() => {
setEditingRedemption(record);
setShowEdit(true);
}}
disabled={record.status !== 1}
>
{t('编辑')}
</Button>
</div>
),
},
];
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 (
<>
<EditRedemption
refresh={refresh}
editingRedemption={editingRedemption}
visiable={showEdit}
handleClose={closeEdit}
></EditRedemption>
<Form
onSubmit={() => {
searchRedemptions(searchKeyword, activePage, pageSize).then();
}}
>
<Form.Input
label={t('搜索关键字')}
field='keyword'
icon='search'
iconPosition='left'
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Divider style={{ margin: '5px 0 15px 0' }} />
<div>
<Button
theme='light'
type='primary'
style={{ marginRight: 8 }}
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
label={t('复制所选兑换码')}
type='warning'
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
<Table
style={{ marginTop: 20 }}
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOpts: [10, 20, 50, 100],
formatPageText: (page) =>
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}
></Table>
</>
);
};
export default RedemptionsTable;

View File

@@ -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 (
<div>
<Layout>
<Layout.Header></Layout.Header>
<Layout.Content>
<div
style={{
justifyContent: 'center',
display: 'flex',
marginTop: 120,
}}
>
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
{t('新用户注册')}
</Title>
<Form size='large'>
<Form.Input
field={'username'}
label={t('用户名')}
placeholder={t('用户名')}
name='username'
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name='password'
type='password'
onChange={(value) => handleChange('password', value)}
/>
<Form.Input
field={'password2'}
label={t('确认密码')}
placeholder={t('确认密码')}
name='password2'
type='password'
onChange={(value) => handleChange('password2', value)}
/>
{showEmailVerification ? (
<>
<Form.Input
field={'email'}
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
onChange={(value) => handleChange('email', value)}
name='email'
type='email'
suffix={
<Button
onClick={sendVerificationCode}
disabled={loading}
>
{t('获取验证码')}
</Button>
}
/>
<Form.Input
field={'verification_code'}
label={t('验证码')}
placeholder={t('输入验证码')}
onChange={(value) =>
handleChange('verification_code', value)
}
name='verification_code'
/>
</>
) : (
<></>
)}
<Button
theme='solid'
style={{ width: '100%' }}
type={'primary'}
size='large'
htmlType={'submit'}
onClick={handleSubmit}
>
{t('注册')}
</Button>
</Form>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 20,
}}
>
<Text>
{t('已有账户?')}
<Link to='/login'>{t('点击登录')}</Link>
</Text>
</div>
{status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.telegram_oauth ||
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
{t('第三方登录')}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
{status.github_oauth ? (
<Button
type='primary'
icon={<IconGithubLogo />}
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
) : (
<></>
)}
{status.oidc_enabled ? (
<Button
type='primary'
icon={<OIDCIcon />}
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
/>
) : (
<></>
)}
{status.linuxdo_oauth ? (
<Button
icon={<LinuxDoIcon />}
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
type='primary'
style={{ color: 'rgba(var(--semi-green-5), 1)' }}
icon={<Icon svg={<WeChatIcon />} />}
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
</div>
{status.telegram_oauth ? (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 5,
}}
>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
</>
) : (
<></>
)}
</>
) : (
<></>
)}
</Card>
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size={'small'}
centered={true}
>
<div
style={{
display: 'flex',
alignItem: 'center',
flexDirection: 'column',
}}
>
<img src={status.wechat_qrcode} />
</div>
<div style={{ textAlign: 'center' }}>
<p>
{t(
'微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
)}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
{turnstileEnabled ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
) : (
<></>
)}
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default RegisterForm;

View File

@@ -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: <IconCalendarClock />,
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('API令牌'),
itemKey: 'token',
to: '/token',
icon: <IconKey />,
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
icon: <IconHistogram />,
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
icon: <IconImage />,
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
icon: <IconChecklistStroked />,
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: <IconCreditCard />,
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
icon: <IconUser />,
},
],
[t],
);
const adminItems = useMemo(
() => [
{
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
icon: <IconLayers />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
icon: <IconGift />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
icon: <IconUser />,
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
icon: <IconSetting />,
},
],
[isAdmin(), t],
);
const chatMenuItems = useMemo(
() => [
{
text: 'Playground',
itemKey: 'playground',
to: '/playground',
icon: <IconCommentStroked />,
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
icon: <IconComment />,
},
],
[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 (
<>
<Nav
className='custom-sidebar-nav'
style={{
width: isCollapsed ? '60px' : '200px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
borderRight: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-1)',
borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
position: 'relative',
zIndex: 95,
height: '100%',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
}}
defaultIsCollapsed={
localStorage.getItem('default_collapse_sidebar') === 'true'
}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
// styleDispatch({ type: 'SET_SIDER', payload: true });
styleDispatch({ type: 'SET_SIDER_COLLAPSED', payload: collapsed });
localStorage.setItem('default_collapse_sidebar', collapsed);
// 确保在收起侧边栏时有选中的项目,避免不必要的计算
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle={navItemStyle}
hoverStyle={navItemHoverStyle}
selectedStyle={navItemSelectedStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
return (
<Link
style={{ textDecoration: 'none' }}
to={routerMapState[props.itemKey] || routerMap[props.itemKey]}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
if (key.itemKey.toString().startsWith('chat')) {
styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
}
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* Chat Section - Only show if there are chat items */}
{chatMenuItems.map((item) => {
if (item.items && item.items.length > 0) {
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
>
{item.items.map((subItem) => (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={subItem.text}
/>
))}
</Nav.Sub>
);
} else {
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
/>
);
}
})}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Workspace Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
{workspaceItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
{isAdmin() && (
<>
{/* Divider */}
<Divider style={dividerStyle} />
{/* Admin Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
{adminItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
</>
)}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Finance Management Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
{financeItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
<Nav.Footer
style={{
paddingBottom: styleState?.isMobile ? '112px' : '',
}}
collapseButton={true}
collapseText={(collapsed) => {
if (collapsed) {
return t('展开侧边栏');
}
return t('收起侧边栏');
}}
/>
</Nav>
</>
);
};
export default SiderBar;

View File

@@ -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 (
<Tag color={color} size='large'>
{durationSec}
</Tag>
);
}
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 <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
title: '结束时间',
dataIndex: 'finish_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
title: '进度',
dataIndex: 'progress',
width: 50,
render: (text, record, index) => {
return (
<div>
{
// 转换例如100%为数字100如果text未定义返回0
isNaN(text.replace('%', '')) ? (
text
) : (
<Progress
width={42}
type='circle'
showInfo={true}
percent={Number(text.replace('%', '') || 0)}
aria-label='drawing progress'
/>
)
}
</div>
);
},
},
{
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 (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
onClick={() => {
copyText(text); // 假设copyText是用于文本复制的函数
}}
>
{' '}
{text}{' '}
</Tag>
</div>
);
},
},
{
title: '平台',
dataIndex: 'platform',
render: (text, record, index) => {
return <div>{renderPlatform(text)}</div>;
},
},
{
title: '类型',
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
title: '任务ID点击查看详情',
dataIndex: 'task_id',
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
//style={{width: 100}}
onClick={() => {
setModalContent(JSON.stringify(record, null, 2));
setIsModalOpen(true);
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
title: '任务状态',
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: '失败原因',
dataIndex: 'fail_reason',
render: (text, record, index) => {
// 如果text未定义返回替代文本例如空字符串''或其他
if (!text) {
return '无';
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.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 (
<Label basic color='grey'>
{' '}
生成音乐{' '}
</Label>
);
case 'LYRICS':
return (
<Label basic color='pink'>
{' '}
生成歌词{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
const renderPlatform = (type) => {
switch (type) {
case 'suno':
return (
<Label basic color='green'>
{' '}
Suno{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
const renderStatus = (type) => {
switch (type) {
case 'SUCCESS':
return (
<Label basic color='green'>
{' '}
成功{' '}
</Label>
);
case 'NOT_START':
return (
<Label basic color='black'>
{' '}
未启动{' '}
</Label>
);
case 'SUBMITTED':
return (
<Label basic color='yellow'>
{' '}
队列中{' '}
</Label>
);
case 'IN_PROGRESS':
return (
<Label basic color='blue'>
{' '}
执行中{' '}
</Label>
);
case 'FAILURE':
return (
<Label basic color='red'>
{' '}
失败{' '}
</Label>
);
case 'QUEUED':
return (
<Label basic color='red'>
{' '}
排队中{' '}
</Label>
);
case 'UNKNOWN':
return (
<Label basic color='red'>
{' '}
未知{' '}
</Label>
);
case '':
return (
<Label basic color='black'>
{' '}
正在提交{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
return (
<>
<Layout>
<Form layout='horizontal' labelPosition='inset'>
<>
{isAdminUser && (
<Form.Input
field='channel_id'
label='渠道 ID'
style={{ width: '236px', marginBottom: '10px' }}
value={channel_id}
placeholder={'可选值'}
name='channel_id'
onChange={(value) => handleInputChange(value, 'channel_id')}
/>
)}
<Form.Input
field='task_id'
label={'任务 ID'}
style={{ width: '236px', marginBottom: '10px' }}
value={task_id}
placeholder={'可选值'}
name='task_id'
onChange={(value) => handleInputChange(value, 'task_id')}
/>
<Form.DatePicker
field='start_timestamp'
label={'起始时间'}
style={{ width: '236px', marginBottom: '10px' }}
initValue={start_timestamp}
value={start_timestamp}
type='dateTime'
name='start_timestamp'
onChange={(value) => handleInputChange(value, 'start_timestamp')}
/>
<Form.DatePicker
field='end_timestamp'
fluid
label={'结束时间'}
style={{ width: '236px', marginBottom: '10px' }}
initValue={end_timestamp}
value={end_timestamp}
type='dateTime'
name='end_timestamp'
onChange={(value) => handleInputChange(value, 'end_timestamp')}
/>
<Button
label={'查询'}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
>
查询
</Button>
</>
</Form>
<Card>
<Table
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: ITEMS_PER_PAGE,
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
}}
loading={loading}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
</Layout>
</>
);
};
export default LogsTable;

View File

@@ -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 <Tag size='large'>{t('普通用户')}</Tag>;
case 10:
return (
<Tag color='yellow' size='large'>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large'>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large'>
{t('未知身份')}
</Tag>
);
}
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('用户名'),
dataIndex: 'username',
},
{
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
},
{
title: t('统计信息'),
dataIndex: 'info',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('剩余额度')}>
<Tag color='white' size='large'>
{renderQuota(record.quota)}
</Tag>
</Tooltip>
<Tooltip content={t('已用额度')}>
<Tag color='white' size='large'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('调用次数')}>
<Tag color='white' size='large'>
{renderNumber(record.request_count)}
</Tag>
</Tooltip>
</Space>
</div>
);
},
},
{
title: t('邀请信息'),
dataIndex: 'invite',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('邀请人数')}>
<Tag color='white' size='large'>
{renderNumber(record.aff_count)}
</Tag>
</Tooltip>
<Tooltip content={t('邀请总收益')}>
<Tag color='white' size='large'>
{renderQuota(record.aff_history_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('邀请人ID')}>
{record.inviter_id === 0 ? (
<Tag color='white' size='large'>
{t('无')}
</Tag>
) : (
<Tag color='white' size='large'>
{record.inviter_id}
</Tag>
)}
</Tooltip>
</Space>
</div>
);
},
},
{
title: t('角色'),
dataIndex: 'role',
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red'>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
render: (text, record, index) => (
<div>
{record.DeletedAt !== null ? (
<></>
) : (
<>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'promote', record);
}}
>
<Button theme='light' type='warning' style={{ marginRight: 1 }}>
{t('提升')}
</Button>
</Popconfirm>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'demote', record);
}}
>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
>
{t('降级')}
</Button>
</Popconfirm>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
style={{ marginRight: 1 }}
onClick={async () => {
manageUser(record.id, 'disable', record);
}}
>
{t('禁用')}
</Button>
) : (
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async () => {
manageUser(record.id, 'enable', record);
}}
disabled={record.status === 3}
>
{t('启用')}
</Button>
)}
<Button
theme='light'
type='tertiary'
style={{ marginRight: 1 }}
onClick={() => {
setEditingUser(record);
setShowEditUser(true);
}}
>
{t('编辑')}
</Button>
<Popconfirm
title={t('确定是否要注销此用户?')}
content={t('相当于删除用户,此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageUser(record.id, 'delete', record).then(() => {
removeRecord(record.id);
});
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('注销')}
</Button>
</Popconfirm>
</>
)}
</div>
),
},
];
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 <Tag size='large'>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red'>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey'>
{t('未知状态')}
</Tag>
);
}
};
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 (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<Form
onSubmit={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
labelPosition='left'
>
<div style={{ display: 'flex' }}>
<Space>
<Tooltip
content={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
>
<Form.Input
label={t('搜索关键字')}
icon='search'
field='keyword'
iconPosition='left'
placeholder={t('搜索关键字')}
value={searchKeyword}
loading={searching}
onChange={(value) => handleKeywordChange(value)}
/>
</Tooltip>
<Form.Select
field='group'
label={t('分组')}
optionList={groupOptions}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
/>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
>
{t('查询')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => {
setShowAddUser(true);
}}
>
{t('添加用户')}
</Button>
</Space>
</div>
</Form>
<Table
columns={columns}
dataSource={users}
pagination={{
formatPageText: (page) =>
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;

View File

@@ -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 (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
size="large"
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
size="large"
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
size="large"
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
size="large"
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderEmailLoginForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3}>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="username"
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name="username"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('username', value)}
prefix={<IconMail />}
/>
<Form.Input
field="password"
label={t('密码')}
placeholder={t('请输入您的密码')}
name="password"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loginLoading}
>
{t('继续')}
</Button>
<Button
theme="borderless"
type='tertiary'
className="w-full !rounded-full"
size="large"
onClick={handleResetPasswordClick}
loading={resetPasswordLoading}
>
{t('忘记密码?')}
</Button>
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
{t('其他登录选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
// 微信登录模态框
const renderWeChatLoginModal = () => {
return (
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size="small"
centered={true}
okButtonProps={{
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
</div>
<Form size="large">
<Form.Input
field="wechat_verification_code"
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
/>
</Form>
</Modal>
);
};
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default LoginForm;

View File

@@ -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 (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
return <Loading prompt={prompt} />;
};
export default OAuth2Callback;

View File

@@ -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 (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
</div>
<div className="px-2 py-8">
{!isValidResetLink && (
<Banner
type="danger"
description={t('无效的重置链接,请重新发起密码重置请求')}
className="mb-4 !rounded-lg"
closeIcon={null}
/>
)}
<Form
getFormApi={(api) => setFormApi(api)}
initValues={{ email: email || '', newPassword: newPassword || '' }}
className="space-y-4"
>
<Form.Input
field="email"
label={t('邮箱')}
name="email"
size="large"
className="!rounded-md"
disabled={true}
prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')}
/>
{newPassword && (
<Form.Input
field="newPassword"
label={t('新密码')}
name="newPassword"
size="large"
className="!rounded-md"
disabled={true}
prefix={<IconLock />}
suffix={
<Button
icon={<IconCopy />}
type="tertiary"
theme="borderless"
onClick={async () => {
await copy(newPassword);
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
}}
>
{t('复制')}
</Button>
}
/>
)}
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton || newPassword || !isValidResetLink}
>
{newPassword ? t('密码重置完成') : t('确认重置密码')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
);
};
export default PasswordResetConfirm;

View File

@@ -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 (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="email"
label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')}
name="email"
size="large"
className="!rounded-md"
value={email}
onChange={handleChange}
prefix={<IconMail />}
/>
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default PasswordResetForm;

View File

@@ -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 (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
size="large"
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
size="large"
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
size="large"
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
size="large"
onClick={handleEmailRegisterClick}
loading={emailRegisterLoading}
>
<span className="ml-3">{t('使用 用户名 注册')}</span>
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderEmailRegisterForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="username"
label={t('用户名')}
placeholder={t('请输入用户名')}
name="username"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('username', value)}
prefix={<IconUser />}
/>
<Form.Input
field="password"
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<Form.Input
field="password2"
label={t('确认密码')}
placeholder={t('确认密码')}
name="password2"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />}
/>
{showEmailVerification && (
<>
<Form.Input
field="email"
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
name="email"
type="email"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('email', value)}
prefix={<IconMail />}
suffix={
<Button
onClick={sendVerificationCode}
loading={verificationCodeLoading}
size="small"
className="!rounded-md mr-2"
>
{t('获取验证码')}
</Button>
}
/>
<Form.Input
field="verification_code"
label={t('验证码')}
placeholder={t('输入验证码')}
name="verification_code"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('verification_code', value)}
prefix={<IconKey />}
/>
</>
)}
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={registerLoading}
>
{t('注册')}
</Button>
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading}
>
{t('其他注册选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderWeChatLoginModal = () => {
return (
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size="small"
centered={true}
okButtonProps={{
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
</div>
<Form size="large">
<Form.Input
field="wechat_verification_code"
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
/>
</Form>
</Modal>
);
};
return (
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default RegisterForm;

View File

@@ -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 (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size={size}
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('{{name}}', { name }) : t('加载中...')}
</span>
</div>
</div>
);
};
export default Loading;

View File

@@ -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'
>
<path
d='M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z'

View File

@@ -11,8 +11,8 @@ const WeChatIcon = () => {
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='5091'
width='16'
height='16'
width='20'
height='20'
>
<path
d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'

View File

@@ -0,0 +1,513 @@
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import './markdown.css';
import RemarkMath from 'remark-math';
import RemarkBreaks from 'remark-breaks';
import RehypeKatex from 'rehype-katex';
import RemarkGfm from 'remark-gfm';
import RehypeHighlight from 'rehype-highlight';
import { useRef, useState, useEffect, useMemo } from 'react';
import mermaid from 'mermaid';
import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import clsx from 'clsx';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
import { IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
});
export function Mermaid(props) {
const ref = useRef(null);
const [hasError, setHasError] = useState(false);
useEffect(() => {
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 (
<div
className={clsx('mermaid-container')}
style={{
cursor: 'pointer',
overflow: 'auto',
padding: '12px',
border: '1px solid var(--semi-color-border)',
borderRadius: '8px',
backgroundColor: 'var(--semi-color-bg-1)',
margin: '12px 0',
}}
ref={ref}
onClick={() => viewSvgInNewWindow()}
>
{props.code}
</div>
);
}
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('<!DOCTYPE') ||
refText?.startsWith('<svg') ||
refText?.startsWith('<?xml')
) {
setHtmlCode(refText);
}
}, 600);
// 处理代码块的换行
useEffect(() => {
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 (
<>
<pre
ref={ref}
style={{
position: 'relative',
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: '6px',
padding: '12px',
margin: '12px 0',
overflow: 'auto',
fontSize: '14px',
lineHeight: '1.4',
}}
>
<div
className="copy-code-button"
style={{
position: 'absolute',
top: '8px',
right: '8px',
display: 'flex',
gap: '4px',
zIndex: 10,
opacity: 0,
transition: 'opacity 0.2s ease',
}}
>
<Tooltip content={t('复制代码')}>
<Button
size="small"
theme="borderless"
icon={<IconCopy />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
const code = ref.current.querySelector('code')?.innerText ?? '';
copy(code).then((success) => {
if (success) {
Toast.success(t('代码已复制到剪贴板'));
} else {
Toast.error(t('复制失败,请手动复制'));
}
});
}
}}
style={{
padding: '4px',
backgroundColor: 'var(--semi-color-bg-2)',
borderRadius: '4px',
cursor: 'pointer',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
}}
/>
</Tooltip>
</div>
{props.children}
</pre>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{htmlCode.length > 0 && (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: '8px',
padding: '16px',
margin: '12px 0',
backgroundColor: 'var(--semi-color-bg-1)',
}}
>
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
HTML预览:
</div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
</div>
)}
</>
);
}
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 (
<div
style={{
position: 'absolute',
bottom: '8px',
right: '8px',
left: '8px',
display: 'flex',
justifyContent: 'center',
}}
>
<Button size="small" onClick={toggleCollapsed} theme="solid">
{t('显示更多')}
</Button>
</div>
);
}
return null;
};
return (
<div style={{ position: 'relative' }}>
<code
className={clsx(props?.className)}
ref={ref}
style={{
maxHeight: collapsed ? '400px' : 'none',
overflowY: 'hidden',
display: 'block',
padding: '8px 12px',
backgroundColor: 'var(--semi-color-fill-0)',
borderRadius: '4px',
fontSize: '13px',
lineHeight: '1.4',
}}
>
{props.children}
</code>
{renderShowMoreButton()}
</div>
);
}
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]*?)(<!DOCTYPE html>)/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 (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={rehypePluginsBase}
components={{
pre: PreCode,
code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
a: (aProps) => {
const href = aProps.href || '';
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
return (
<figure style={{ margin: '12px 0' }}>
<audio controls src={href} style={{ width: '100%' }}></audio>
</figure>
);
}
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return (
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
<source src={href} />
</video>
);
}
const isInternal = /^\/#/i.test(href);
const target = isInternal ? '_self' : aProps.target ?? '_blank';
return (
<a
{...aProps}
target={target}
style={{
color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
textDecoration: 'none',
}}
onMouseEnter={(e) => {
e.target.style.textDecoration = 'underline';
}}
onMouseLeave={(e) => {
e.target.style.textDecoration = 'none';
}}
/>
);
},
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
blockquote: (props) => (
<blockquote
{...props}
style={{
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
paddingLeft: '16px',
margin: '12px 0',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
padding: '8px 16px',
borderRadius: '0 4px 4px 0',
fontStyle: 'italic',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
table: (props) => (
<div style={{ overflow: 'auto', margin: '12px 0' }}>
<table
{...props}
style={{
width: '100%',
borderCollapse: 'collapse',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
borderRadius: '6px',
overflow: 'hidden',
}}
/>
</div>
),
th: (props) => (
<th
{...props}
style={{
padding: '8px 12px',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
fontWeight: 'bold',
textAlign: 'left',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
td: (props) => (
<td
{...props}
style={{
padding: '8px 12px',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
}}
>
{escapedContent}
</ReactMarkdown>
);
}
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 (
<div
className={clsx('markdown-body', className)}
style={{
fontSize: `${fontSize}px`,
fontFamily: fontFamily,
lineHeight: '1.6',
color: 'var(--semi-color-text-0)',
...style,
}}
dir="auto"
{...otherProps}
>
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '16px',
color: 'var(--semi-color-text-2)',
}}>
<div style={{
width: '16px',
height: '16px',
border: '2px solid var(--semi-color-border)',
borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
正在渲染...
</div>
) : (
<MarkdownContent
content={content}
className={className}
animated={animated}
previousContentLength={previousContentLength}
/>
)}
</div>
);
}
export default MarkdownRenderer;

View File

@@ -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;
}

View File

@@ -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 (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{label}</Typography.Text>
</div>
<Input
name={name}
placeholder={placeholder}
onChange={(value) => onChange(value)}
value={value}
autoComplete='new-password'
/>
</>
);
};
export default TextInput;

View File

@@ -1,21 +0,0 @@
import { Input, InputNumber, Typography } from '@douyinfe/semi-ui';
import React from 'react';
const TextNumberInput = ({ label, name, value, onChange, placeholder }) => {
return (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{label}</Typography.Text>
</div>
<InputNumber
name={name}
placeholder={placeholder}
onChange={(value) => onChange(value)}
value={value}
autoComplete='new-password'
/>
</>
);
};
export default TextNumberInput;

View File

@@ -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 };
}

View File

@@ -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(() => (
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
{isDemoSiteMode && (
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
<div className="flex-shrink-0">
<img
src={logo}
alt={systemName}
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
<div className="flex flex-wrap items-center gap-2">
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
</div>
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
</div>
</div>
</footer>
), [logo, systemName, t, currentYear, isDemoSiteMode]);
useEffect(() => {
loadFooter();
}, []);
return (
<div className="w-full">
{footer ? (
<div
className="custom-footer"
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
customFooter
)}
</div>
);
};
export default FooterBar;

View File

@@ -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) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
</div>
));
}
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 = (
<span>{link.text}</span>
);
if (link.isExternal) {
return (
<a
key={link.itemKey}
href={link.externalLink}
target='_blank'
rel='noopener noreferrer'
className={commonLinkClasses}
onClick={() => handleNavLinkClick(link.itemKey)}
>
{linkContent}
</a>
);
}
let targetPath = link.to;
if (link.itemKey === 'console' && !userState.user) {
targetPath = '/login';
}
return (
<Link
key={link.itemKey}
to={targetPath}
className={commonLinkClasses}
onClick={() => handleNavLinkClick(link.itemKey)}
>
{linkContent}
</Link>
);
});
};
const renderUserArea = () => {
if (isLoading) {
return (
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
<div className="ml-1.5 mr-1">
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
</div>
</div>
);
}
if (userState.user) {
return (
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item
onClick={() => {
navigate('/console/personal');
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"
>
<div className="flex items-center gap-2">
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('个人设置')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/token');
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"
>
<div className="flex items-center gap-2">
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('API令牌')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
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"
>
<div className="flex items-center gap-2">
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('钱包')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
<div className="flex items-center gap-2">
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('退出')}</span>
</div>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme="borderless"
type="tertiary"
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
>
<Avatar
size="extra-small"
color={stringToColor(userState.user.username)}
className="mr-1"
>
{userState.user.username[0].toUpperCase()}
</Avatar>
<span className="hidden md:inline">
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
{userState.user.username}
</Typography.Text>
</span>
<IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
</Button>
</Dropdown>
);
} 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 (
<div className="flex items-center">
<Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
<Button
theme="borderless"
type="tertiary"
className={loginButtonClasses}
>
<span className={loginButtonTextSpanClass}>
{t('登录')}
</span>
</Button>
</Link>
{showRegisterButton && (
<div className="hidden md:block">
<Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
<Button
theme="solid"
type="primary"
className={registerButtonClasses}
>
<span className={registerButtonTextSpanClass}>
{t('注册')}
</span>
</Button>
</Link>
</div>
)}
</div>
);
}
};
// 检查当前路由是否以/console开头
const isConsoleRoute = location.pathname.startsWith('/console');
return (
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={styleState.isMobile}
/>
<div className="w-full px-2">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<div className="md:hidden">
<Button
icon={
isConsoleRoute
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
}
aria-label={
isConsoleRoute
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
}
onClick={() => {
if (isConsoleRoute) {
// 控制侧边栏的显示/隐藏,无论是否移动设备
styleDispatch(styleActions.toggleSider());
} else {
// 控制HeaderBar自己的移动菜单
setMobileMenuOpen(!mobileMenuOpen);
}
}}
theme="borderless"
type="tertiary"
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
/>
</div>
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
{isLoading ? (
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
) : (
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
)}
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2">
{isLoading ? (
<Skeleton.Title style={{ width: 120, height: 24 }} />
) : (
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
bg-clip-text text-transparent">
{systemName}
</Typography.Title>
)}
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
size="small"
shape='circle'
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
</div>
</Link>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<div className="md:hidden">
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
size="small"
shape='circle'
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
</div>
)}
<nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
{renderNavLinks(false, isLoading)}
</nav>
</div>
<div className="flex items-center gap-2 md:gap-3">
{isNewYear && (
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
Happy New Year!!! 🎉
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme="borderless"
type="tertiary"
icon={<span className="text-xl">🎉</span>}
aria-label="New Year"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
/>
</Dropdown>
)}
<Button
icon={<IconBell className="text-lg" />}
aria-label={t('系统公告')}
onClick={() => setNoticeVisible(true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Button
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
aria-label={t('切换主题')}
onClick={() => setTheme(theme === 'dark' ? false : true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<CN title="中文" className="!w-5 !h-auto" />
<span>中文</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleLanguageChange('en')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<GB title="English" className="!w-5 !h-auto" />
<span>English</span>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
icon={<IconLanguage className="text-lg" />}
aria-label={t('切换语言')}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
</Dropdown>
{renderUserArea()}
</div>
</div>
</div>
<div className="md:hidden">
<div
className={`
absolute top-16 left-0 right-0 bg-semi-color-bg-0
shadow-lg p-3
transform transition-all duration-300 ease-in-out
${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
`}
>
<nav className="flex flex-col gap-1">
{renderNavLinks(true, isLoading)}
</nav>
</div>
</div>
</header>
);
};
export default HeaderBar;

View File

@@ -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 <div className="py-12"><Empty description={t('加载中...')} /></div>;
}
if (!noticeContent) {
return (
<div className="py-12">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('暂无公告')}
/>
</div>
);
}
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
/>
);
};
return (
<Modal
title={t('系统公告')}
visible={visible}
onCancel={onClose}
footer={(
<div className="flex justify-end">
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
</div>
)}
size={isMobile ? 'full-width' : 'large'}
>
{renderContent()}
</Modal>
);
};
export default NoticeModal;

View File

@@ -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 (
<Layout
style={{
@@ -84,19 +84,18 @@ const PageLayout = () => {
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)',
}}
>
<HeaderBar />
</Header>
<Layout
style={{
marginTop: styleState.isMobile ? '0' : '56px',
height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)',
marginTop: '64px',
height: 'calc(100vh - 64px)',
overflow: styleState.isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
@@ -107,13 +106,11 @@ const PageLayout = () => {
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)',
}}
>
<SiderBar />
@@ -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 = () => {
<Content
style={{
flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'auto',
overflowY: styleState.isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch',
padding: styleState.shouldInnerPadding ? '24px' : '0',
padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
position: 'relative',
marginTop: styleState.isMobile ? '2px' : '0',
}}
>
<App />
</Content>
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
{!shouldHideFooter && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />

View File

@@ -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);

View File

@@ -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 (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
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 (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
>
{item.items.map((subItem) => {
const isSubSelected = selectedKeys.includes(subItem.itemKey);
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
return (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
{subItem.text}
</span>
}
/>
);
})}
</Nav.Sub>
);
} else {
return renderNavItem(item);
}
};
return (
<div
className="sidebar-container"
style={{ width: isCollapsed ? '60px' : '180px' }}
>
<Nav
className="sidebar-nav custom-sidebar-nav"
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
selectedStyle="sidebar-nav-item-selected"
renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
// 如果没有路由,直接返回元素
if (!to) return itemElement;
return (
<Link
style={{ textDecoration: 'none' }}
to={to}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
{/* 管理员区域 - 只在管理员时显示 */}
{isAdmin() && (
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
</div>
</div>
);
};
export default SiderBar;

View File

@@ -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 <CustomInputRender {...props} />;
}, []);
return (
<Card
className="h-full"
bordered={false}
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
{styleState.isMobile ? (
<div className="pt-4"></div>
) : (
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel}
theme="borderless"
type="primary"
size="small"
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
</div>
</div>
</div>
)}
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
ref={chatRef}
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
renderChatBoxTitle: () => 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('请输入您的问题...')}
/>
</div>
</Card>
);
};
export default ChatArea;

View File

@@ -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 `<span style="color: ${color}">${match}</span>`;
}
);
};
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 (
<div style={codeThemeStyles.noContent}>
<span>{placeholderText}</span>
</div>
);
}
const warningTop = contentMetrics.isLarge ? '52px' : '12px';
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
return (
<div style={codeThemeStyles.container} className="h-full">
{/* 性能警告 */}
{contentMetrics.isLarge && (
<div style={codeThemeStyles.performanceWarning}>
<span></span>
<span>
{contentMetrics.isVeryLarge
? t('内容较大,已启用性能优化模式')
: t('内容较大,部分功能可能受限')}
</span>
</div>
)}
{/* 复制按钮 */}
<div
style={{
...codeThemeStyles.actionButton,
...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
top: warningTop,
right: '12px',
}}
onMouseEnter={() => setIsHoveringCopy(true)}
onMouseLeave={() => setIsHoveringCopy(false)}
>
<Tooltip content={copied ? t('已复制') : t('复制代码')}>
<Button
icon={<Copy size={14} />}
onClick={handleCopy}
size="small"
theme="borderless"
style={{
backgroundColor: 'transparent',
border: 'none',
color: copied ? '#4ade80' : '#d4d4d4',
padding: '6px',
}}
/>
</Tooltip>
</div>
{/* 代码内容 */}
<div
style={{
...codeThemeStyles.content,
paddingTop: contentPadding,
}}
className="model-settings-scroll"
>
{isProcessing ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: '#888'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid #444',
borderTop: '2px solid #888',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '8px'
}} />
{t('正在处理大内容...')}
</div>
) : (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
)}
</div>
{/* 展开/收起按钮 */}
{contentMetrics.isLarge && !isProcessing && (
<div style={{
...codeThemeStyles.actionButton,
bottom: '12px',
left: '50%',
transform: 'translateX(-50%)',
}}>
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
<Button
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
onClick={handleToggleExpand}
size="small"
theme="borderless"
style={{
backgroundColor: 'transparent',
border: 'none',
color: '#d4d4d4',
padding: '6px 12px',
}}
>
{isExpanded ? t('收起') : t('展开')}
{!isExpanded && (
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
</span>
)}
</Button>
</Tooltip>
</div>
)}
</div>
);
};
export default CodeViewer;

View File

@@ -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: (
<div className="flex items-center gap-2">
<Download size={14} />
{t('导出配置')}
</div>
),
},
{
node: 'item',
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<Upload size={14} />
{t('导入配置')}
</div>
),
},
{
node: 'divider',
},
{
node: 'item',
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<RotateCcw size={14} />
{t('重置配置')}
</div>
),
},
];
if (styleState.isMobile) {
// 移动端显示简化的下拉菜单
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</>
);
}
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
{/* 配置状态信息和重置按钮 */}
<div className="flex items-center justify-between">
<Typography.Text className="text-xs text-gray-500">
{getConfigStatus()}
</Typography.Text>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={handleReset}
className="!rounded-full !text-xs !px-2"
/>
</div>
{/* 导出和导入按钮 */}
<div className="flex gap-2">
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导入')}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
export default ConfigManager;

View File

@@ -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 (
<div className="p-2 sm:p-4">
<div
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}
<div className="flex-1">
{inputNode}
</div>
{/* 发送按钮 - 右边 */}
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;

View File

@@ -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 (
<div className="space-y-4">
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
自定义请求体模式
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后将使用您自定义的请求体发送API请求模型配置面板的参数设置将被忽略。"
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
closable={false}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<Check size={14} />
<Typography.Text className="text-xs">
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<X size={14} />
<Typography.Text className="text-xs">
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
>
格式化
</Button>
</div>
</div>
<TextArea
value={localValue}
onChange={handleValueChange}
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
autosize={{ minRows: 8, maxRows: 20 }}
className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
style={{
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: '1.5',
}}
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;

View File

@@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Typography,
Tabs,
TabPane,
Button,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Code,
Zap,
Clock,
X,
Eye,
Send,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer';
const DebugPanel = ({
debugData,
activeDebugTab,
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
customRequestMode,
}) => {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState(activeDebugTab);
useEffect(() => {
setActiveKey(activeDebugTab);
}, [activeDebugTab]);
const handleTabChange = (key) => {
setActiveKey(key);
onActiveDebugTabChange(key);
};
const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => handleTabChange(item.itemKey)}
>
{item.tab}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
{pos === 'start' ? (
<div style={style} onClick={handleArrowClick}>
</div>
) : (
<div style={style} onClick={handleArrowClick}>
</div>
)}
</Dropdown>
);
};
return (
<Card
className="h-full flex flex-col"
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
{styleState.isMobile && onCloseDebugPanel && (
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
renderArrow={renderArrow}
type="card"
collapsible
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeKey}
onChange={handleTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<CodeViewer
content={debugData.previewRequest}
title="preview"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Send size={16} />
{t('实际请求体')}
</div>
} itemKey="request">
<CodeViewer
content={debugData.request}
title="request"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应')}
</div>
} itemKey="response">
<CodeViewer
content={debugData.response}
title="response"
language="json"
/>
</TabPane>
</Tabs>
</div>
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
{(debugData.timestamp || debugData.previewTimestamp) && (
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{activeKey === 'preview' && debugData.previewTimestamp
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
: debugData.timestamp
? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
: ''}
</Typography.Text>
</div>
)}
</div>
</Card>
);
};
export default DebugPanel;

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({
styleState,
showSettings,
showDebugPanel,
onToggleSettings,
onToggleDebugPanel,
}) => {
if (!styleState.isMobile) return null;
return (
<>
{/* 设置按钮 */}
{!showSettings && (
<Button
icon={<Settings size={18} />}
style={{
position: 'fixed',
right: 16,
bottom: 90,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
}}
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
/>
)}
{/* 调试按钮 */}
{!showSettings && (
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
style={{
position: 'fixed',
right: 16,
bottom: 140,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: showDebugPanel
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden !rounded-full !p-0"
/>
)}
</>
);
};
export default FloatingButtons;

View File

@@ -0,0 +1,113 @@
import React from 'react';
import {
Input,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
Image,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
};
const handleUpdateImageUrl = (index, value) => {
const newUrls = [...imageUrls];
newUrls[index] = value;
onImageUrlsChange(newUrls);
};
const handleRemoveImageUrl = (index) => {
const newUrls = imageUrls.filter((_, i) => i !== index);
onImageUrlsChange(newUrls);
};
return (
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText="启用"
uncheckedText="停用"
size="small"
className="flex-shrink-0"
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={!imageEnabled || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
disabled={!imageEnabled || disabled}
/>
</div>
))}
</div>
</div>
);
};
export default ImageUrlInput;

View File

@@ -0,0 +1,121 @@
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
UserCheck,
Edit,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({
message,
styleState,
onMessageReset,
onMessageCopy,
onMessageDelete,
onRoleToggle,
onMessageEdit,
isAnyMessageGenerating = false,
isEditing = false
}) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating || isEditing;
const canToggleRole = message.role === 'assistant' || message.role === 'system';
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('复制')}
/>
</Tooltip>
)}
{canEdit && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageEdit(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-yellow-600 hover:!bg-yellow-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('编辑')}
/>
</Tooltip>
)}
{canToggleRole && !isLoading && (
<Tooltip
content={
shouldDisableActions
? t('操作暂时被禁用')
: message.role === 'assistant'
? t('切换为System角色')
: t('切换为Assistant角色')
}
position="top"
>
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
};
export default MessageActions;

View File

@@ -0,0 +1,313 @@
import React, { useRef, useEffect } from 'react';
import {
Typography,
TextArea,
Button,
} from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import {
Loader2,
Check,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageContent = ({
message,
className,
styleState,
onToggleReasoningExpansion,
isEditing = false,
onEditSave,
onEditCancel,
editValue,
onEditValueChange
}) => {
const { t } = useTranslation();
const previousContentLengthRef = useRef(0);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
useEffect(() => {
if (!isThinkingStatus) {
previousContentLengthRef.current = 0;
lastContentRef.current = '';
}
}, [isThinkingStatus]);
if (message.status === 'error') {
let errorText;
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
errorText = textContent && textContent.text && typeof textContent.text === 'string'
? textContent.text
: t('请求发生错误');
} else if (typeof message.content === 'string') {
errorText = message.content;
} else {
errorText = t('请求发生错误');
}
return (
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
<Typography.Text type="danger" className="text-sm">
{errorText}
</Typography.Text>
</div>
);
}
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = "";
let thinkingSource = null;
const getTextContent = (content) => {
if (Array.isArray(content)) {
const textItem = content.find(item => item.type === 'text');
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
} else if (typeof content === 'string') {
return content;
}
return '';
};
currentDisplayableFinalContent = getTextContent(message.content);
if (message.role === 'assistant') {
let baseContentForDisplay = getTextContent(message.content);
let combinedThinkingContent = "";
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
}
if (baseContentForDisplay.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(baseContentForDisplay.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
}
baseContentForDisplay = replyParts.join('');
}
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
}
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
</div>
</div>
);
}
return (
<div className={className}>
{message.role === 'system' && (
<div className="mb-2 sm:mb-4">
<div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
</div>
<Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
{t('系统消息')}
</Typography.Text>
</div>
</div>
)}
{message.role === 'assistant' && (
<ThinkingContent
message={message}
finalExtractedThinkingContent={finalExtractedThinkingContent}
thinkingSource={thinkingSource}
styleState={styleState}
onToggleReasoningExpansion={onToggleReasoningExpansion}
/>
)}
{isEditing ? (
<div className="space-y-3">
<TextArea
value={editValue}
onChange={(value) => onEditValueChange(value)}
placeholder={t('请输入消息内容...')}
autosize={{ minRows: 3, maxRows: 12 }}
style={{
resize: 'vertical',
fontSize: styleState.isMobile ? '14px' : '15px',
lineHeight: '1.6',
}}
className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
/>
<div className="flex items-center gap-2 w-full">
<Button
size="small"
type="danger"
theme="light"
icon={<X size={14} />}
onClick={onEditCancel}
className="flex-1"
>
{t('取消')}
</Button>
<Button
size="small"
type="warning"
theme="solid"
icon={<Check size={14} />}
onClick={onEditSave}
disabled={!editValue || editValue.trim() === ''}
className="flex-1"
>
{t('保存')}
</Button>
</div>
</div>
) : (
(() => {
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
const imageContents = message.content.filter(item => item.type === 'image_url');
return (
<div>
{imageContents.length > 0 && (
<div className="mb-3 space-y-2">
{imageContents.map((imgItem, index) => (
<div key={index} className="max-w-sm">
<img
src={imgItem.image_url.url}
alt={`用户上传的图片 ${index + 1}`}
className="rounded-lg max-w-full h-auto shadow-sm border"
style={{ maxHeight: '300px' }}
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
style={{ display: 'none' }}
>
图片加载失败: {imgItem.image_url.url}
</div>
</div>
))}
</div>
)}
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<MarkdownRenderer
content={textContent.text}
className={message.role === 'user' ? 'user-message' : ''}
animated={false}
previousContentLength={0}
/>
</div>
)}
</div>
);
}
if (typeof message.content === 'string') {
if (message.role === 'assistant') {
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
// 获取上一次的内容长度
let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) {
// 只有当前内容包含上一次内容时,才使用上一次的长度
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
prevLength = lastContentRef.current.length;
}
}
// 更新最后内容的引用
if (isThinkingStatus) {
lastContentRef.current = finalDisplayableFinalContent;
}
return (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<MarkdownRenderer
content={finalDisplayableFinalContent}
className=""
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
</div>
);
}
} else {
return (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<MarkdownRenderer
content={message.content}
className={message.role === 'user' ? 'user-message' : ''}
animated={false}
previousContentLength={0}
/>
</div>
);
}
}
return null;
})()
)}
</div>
);
};
export default MessageContent;

Some files were not shown because too many files have changed in this diff Show More