Commit 1cff048c by neotf Committed by GitHub

Merge branch 'main' into feat-04

parents 1ce5c347 f2fb28cf
name: Publish Docker image (amd64) name: Publish Docker image (alpha)
on: on:
push: push:
tags: branches:
- '*' - alpha
workflow_dispatch: workflow_dispatch:
inputs: inputs:
name: name:
description: 'reason' description: "reason"
required: false required: false
jobs: jobs:
push_to_registries: push_to_registries:
name: Push Docker image to multiple registries name: Push Docker image to multiple registries
...@@ -22,7 +23,7 @@ jobs: ...@@ -22,7 +23,7 @@ jobs:
- name: Save version info - name: Save version info
run: | run: |
git describe --tags > VERSION echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
...@@ -37,6 +38,9 @@ jobs: ...@@ -37,6 +38,9 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
...@@ -44,11 +48,15 @@ jobs: ...@@ -44,11 +48,15 @@ jobs:
images: | images: |
calciumion/new-api calciumion/new-api
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
tags: |
type=raw,value=alpha
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
name: Publish Docker image (arm64) name: Publish Docker image (Multi Registries)
on: on:
push: push:
tags: tags:
- '*' - '*'
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
jobs: jobs:
push_to_registries: push_to_registries:
name: Push Docker image to multiple registries name: Push Docker image to multiple registries
......
...@@ -3,6 +3,11 @@ permissions: ...@@ -3,6 +3,11 @@ permissions:
contents: write contents: write
on: on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push: push:
tags: tags:
- '*' - '*'
...@@ -15,16 +20,16 @@ jobs: ...@@ -15,16 +20,16 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v3 - uses: oven-sh/setup-bun@v2
with: with:
node-version: 18 bun-version: latest
- name: Build Frontend - name: Build Frontend
env: env:
CI: "" CI: ""
run: | run: |
cd web cd web
npm install bun install
REACT_APP_VERSION=$(git describe --tags) npm run build DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
......
...@@ -3,6 +3,11 @@ permissions: ...@@ -3,6 +3,11 @@ permissions:
contents: write contents: write
on: on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push: push:
tags: tags:
- '*' - '*'
...@@ -15,16 +20,16 @@ jobs: ...@@ -15,16 +20,16 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v3 - uses: oven-sh/setup-bun@v2
with: with:
node-version: 18 bun-version: latest
- name: Build Frontend - name: Build Frontend
env: env:
CI: "" CI: ""
run: | run: |
cd web cd web
npm install bun install
REACT_APP_VERSION=$(git describe --tags) npm run build DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
......
...@@ -3,6 +3,11 @@ permissions: ...@@ -3,6 +3,11 @@ permissions:
contents: write contents: write
on: on:
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
push: push:
tags: tags:
- '*' - '*'
...@@ -18,16 +23,16 @@ jobs: ...@@ -18,16 +23,16 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v3 - uses: oven-sh/setup-bun@v2
with: with:
node-version: 18 bun-version: latest
- name: Build Frontend - name: Build Frontend
env: env:
CI: "" CI: ""
run: | run: |
cd web cd web
npm install bun install
REACT_APP_VERSION=$(git describe --tags) npm run build DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd .. cd ..
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v3 uses: actions/setup-go@v3
......
...@@ -92,12 +92,12 @@ func RedisDel(key string) error { ...@@ -92,12 +92,12 @@ func RedisDel(key string) error {
return RDB.Del(ctx, key).Err() return RDB.Del(ctx, key).Err()
} }
func RedisHDelObj(key string) error { func RedisDelKey(key string) error {
if DebugEnabled { if DebugEnabled {
SysLog(fmt.Sprintf("Redis HDEL: key=%s", key)) SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
} }
ctx := context.Background() 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 { func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
......
...@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest { ...@@ -200,10 +200,10 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
} else { } else {
testRequest.MaxTokens = 10 testRequest.MaxTokens = 10
} }
content, _ := json.Marshal("hi")
testMessage := dto.Message{ testMessage := dto.Message{
Role: "user", Role: "user",
Content: content, Content: "hi",
} }
testRequest.Model = model testRequest.Model = model
testRequest.Messages = append(testRequest.Messages, testMessage) testRequest.Messages = append(testRequest.Messages, testMessage)
...@@ -271,6 +271,13 @@ func testAllChannels(notify bool) error { ...@@ -271,6 +271,13 @@ func testAllChannels(notify bool) error {
disableThreshold = 10000000 // a impossible value disableThreshold = 10000000 // a impossible value
} }
gopool.Go(func() { gopool.Go(func() {
// 使用 defer 确保无论如何都会重置运行状态,防止死锁
defer func() {
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
}()
for _, channel := range channels { for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now() tik := time.Now()
...@@ -305,9 +312,7 @@ func testAllChannels(notify bool) error { ...@@ -305,9 +312,7 @@ func testAllChannels(notify bool) error {
channel.UpdateResponseTime(milliseconds) channel.UpdateResponseTime(milliseconds)
time.Sleep(common.RequestInterval) time.Sleep(common.RequestInterval)
} }
testAllChannelsLock.Lock()
testAllChannelsRunning = false
testAllChannelsLock.Unlock()
if notify { if notify {
service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成")
} }
......
...@@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) { ...@@ -623,3 +623,44 @@ func BatchSetChannelTag(c *gin.Context) {
}) })
return 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
}
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"one-api/common" "one-api/common"
"one-api/constant" "one-api/constant"
"one-api/middleware"
"one-api/model" "one-api/model"
"one-api/setting" "one-api/setting"
"one-api/setting/operation_setting" "one-api/setting/operation_setting"
...@@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) { ...@@ -24,14 +25,18 @@ func TestStatus(c *gin.Context) {
}) })
return return
} }
// 获取HTTP统计信息
httpStats := middleware.GetStats()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Server is running", "message": "Server is running",
"http_stats": httpStats,
}) })
return return
} }
func GetStatus(c *gin.Context) { func GetStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) { ...@@ -74,6 +79,9 @@ func GetStatus(c *gin.Context) {
"oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"setup": constant.Setup, "setup": constant.Setup,
"api_info": setting.GetApiInfo(),
"announcements": setting.GetAnnouncements(),
"faq": setting.GetFAQ(),
}, },
}) })
return return
......
...@@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) { ...@@ -119,7 +119,33 @@ func UpdateOption(c *gin.Context) {
}) })
return 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) err = model.UpdateOption(option.Key, option.Value)
if err != nil { if err != nil {
......
...@@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) { ...@@ -106,7 +106,7 @@ func RequestEpay(c *gin.Context) {
payType = "wxpay" payType = "wxpay"
} }
callBackAddress := service.GetCallbackAddress() callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/log") returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
......
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
type UptimeKumaMonitor struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type UptimeKumaGroup struct {
ID int `json:"id"`
Name string `json:"name"`
Weight int `json:"weight"`
MonitorList []UptimeKumaMonitor `json:"monitorList"`
}
type UptimeKumaHeartbeat struct {
Status int `json:"status"`
Time string `json:"time"`
Msg string `json:"msg"`
Ping *float64 `json:"ping"`
}
type UptimeKumaStatusResponse struct {
PublicGroupList []UptimeKumaGroup `json:"publicGroupList"`
}
type UptimeKumaHeartbeatResponse struct {
HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
type MonitorStatus struct {
Name string `json:"name"`
Uptime float64 `json:"uptime"`
Status int `json:"status"`
}
var (
ErrUpstreamNon200 = errors.New("upstream non-200")
ErrTimeout = errors.New("context deadline exceeded")
)
func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return ErrTimeout
}
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ErrUpstreamNon200
}
return json.NewDecoder(resp.Body).Decode(dest)
}
func GetUptimeKumaStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"]
slug := common.OptionMap["UptimeKumaSlug"]
common.OptionMapRWMutex.RUnlock()
if uptimeKumaUrl == "" || slug == "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": []MonitorStatus{},
})
return
}
uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/")
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
client := &http.Client{}
statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug)
heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug)
var (
statusData UptimeKumaStatusResponse
heartbeatData UptimeKumaHeartbeatResponse
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
return getAndDecode(gCtx, client, statusPageUrl, &statusData)
})
g.Go(func() error {
return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData)
})
if err := g.Wait(); err != nil {
switch err {
case ErrUpstreamNon200:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "上游接口出现问题",
})
case ErrTimeout:
c.JSON(http.StatusRequestTimeout, gin.H{
"success": false,
"message": "请求上游接口超时",
})
default:
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
}
return
}
var monitors []MonitorStatus
for _, group := range statusData.PublicGroupList {
for _, monitor := range group.MonitorList {
monitorStatus := MonitorStatus{
Name: monitor.Name,
Uptime: 0.0,
Status: 0,
}
uptimeKey := fmt.Sprintf("%d_24", monitor.ID)
if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists {
monitorStatus.Uptime = uptime
}
heartbeatKey := fmt.Sprintf("%d", monitor.ID)
if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 {
latestHeartbeat := heartbeats[0]
monitorStatus.Status = latestHeartbeat.Status
}
monitors = append(monitors, monitorStatus)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": monitors,
})
}
\ No newline at end of file
package dto package dto
import "encoding/json" import (
"encoding/json"
"one-api/common"
)
type ClaudeMetadata struct { type ClaudeMetadata struct {
UserId string `json:"user_id"` UserId string `json:"user_id"`
...@@ -23,7 +26,7 @@ type ClaudeMediaMessage struct { ...@@ -23,7 +26,7 @@ type ClaudeMediaMessage struct {
Id string `json:"id,omitempty"` Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"` Input any `json:"input,omitempty"`
Content json.RawMessage `json:"content,omitempty"` Content any `json:"content,omitempty"`
ToolUseId string `json:"tool_use_id,omitempty"` ToolUseId string `json:"tool_use_id,omitempty"`
} }
...@@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string { ...@@ -39,15 +42,39 @@ func (c *ClaudeMediaMessage) GetText() string {
} }
func (c *ClaudeMediaMessage) IsStringContent() bool { func (c *ClaudeMediaMessage) IsStringContent() bool {
var content string if c.Content == nil {
return json.Unmarshal(c.Content, &content) == nil return false
}
_, ok := c.Content.(string)
if ok {
return true
}
return false
} }
func (c *ClaudeMediaMessage) GetStringContent() string { func (c *ClaudeMediaMessage) GetStringContent() string {
var content string if c.Content == nil {
if err := json.Unmarshal(c.Content, &content); err == nil { return ""
return content }
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 "" return ""
} }
...@@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string { ...@@ -57,16 +84,12 @@ func (c *ClaudeMediaMessage) GetJsonRowString() string {
} }
func (c *ClaudeMediaMessage) SetContent(content any) { func (c *ClaudeMediaMessage) SetContent(content any) {
jsonContent, _ := json.Marshal(content) c.Content = content
c.Content = jsonContent
} }
func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
var mediaContent []ClaudeMediaMessage mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content)
if err := json.Unmarshal(c.Content, &mediaContent); err == nil {
return mediaContent return mediaContent
}
return make([]ClaudeMediaMessage, 0)
} }
type ClaudeMessageSource struct { type ClaudeMessageSource struct {
...@@ -82,14 +105,36 @@ type ClaudeMessage struct { ...@@ -82,14 +105,36 @@ type ClaudeMessage struct {
} }
func (c *ClaudeMessage) IsStringContent() bool { func (c *ClaudeMessage) IsStringContent() bool {
if c.Content == nil {
return false
}
_, ok := c.Content.(string) _, ok := c.Content.(string)
return ok return ok
} }
func (c *ClaudeMessage) GetStringContent() string { func (c *ClaudeMessage) GetStringContent() string {
if c.IsStringContent() { if c.Content == nil {
return ""
}
switch c.Content.(type) {
case string:
return c.Content.(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 "" return ""
} }
...@@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) { ...@@ -98,15 +143,7 @@ func (c *ClaudeMessage) SetStringContent(content string) {
} }
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
// map content to []ClaudeMediaMessage return common.Any2Type[[]ClaudeMediaMessage](c.Content)
// 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
} }
type Tool struct { type Tool struct {
...@@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) { ...@@ -161,14 +198,8 @@ func (c *ClaudeRequest) SetStringSystem(system string) {
} }
func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
// map content to []ClaudeMediaMessage mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System)
// parse to json return mediaContent
jsonContent, _ := json.Marshal(c.System)
var contentList []ClaudeMediaMessage
if err := json.Unmarshal(jsonContent, &contentList); err == nil {
return contentList
}
return make([]ClaudeMediaMessage, 0)
} }
type ClaudeError struct { type ClaudeError struct {
......
...@@ -37,11 +37,11 @@ type GeneralOpenAIRequest struct { ...@@ -37,11 +37,11 @@ type GeneralOpenAIRequest struct {
Input any `json:"input,omitempty"` Input any `json:"input,omitempty"`
Instruction string `json:"instruction,omitempty"` Instruction string `json:"instruction,omitempty"`
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
Functions any `json:"functions,omitempty"` Functions json.RawMessage `json:"functions,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"` PresencePenalty float64 `json:"presence_penalty,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,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"` Seed float64 `json:"seed,omitempty"`
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
Tools []ToolCallRequest `json:"tools,omitempty"` Tools []ToolCallRequest `json:"tools,omitempty"`
...@@ -50,10 +50,10 @@ type GeneralOpenAIRequest struct { ...@@ -50,10 +50,10 @@ type GeneralOpenAIRequest struct {
LogProbs bool `json:"logprobs,omitempty"` LogProbs bool `json:"logprobs,omitempty"`
TopLogProbs int `json:"top_logprobs,omitempty"` TopLogProbs int `json:"top_logprobs,omitempty"`
Dimensions int `json:"dimensions,omitempty"` Dimensions int `json:"dimensions,omitempty"`
Modalities any `json:"modalities,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"`
Audio any `json:"audio,omitempty"` Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali 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"` WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params // OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"` Usage json.RawMessage `json:"usage,omitempty"`
...@@ -109,7 +109,7 @@ func (r *GeneralOpenAIRequest) ParseInput() []string { ...@@ -109,7 +109,7 @@ func (r *GeneralOpenAIRequest) ParseInput() []string {
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content json.RawMessage `json:"content"` Content any `json:"content"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Prefix *bool `json:"prefix,omitempty"` Prefix *bool `json:"prefix,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`
...@@ -117,7 +117,7 @@ type Message struct { ...@@ -117,7 +117,7 @@ type Message struct {
ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"` ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent parsedContent []MediaContent
parsedStringContent *string //parsedStringContent *string
} }
type MediaContent struct { type MediaContent struct {
...@@ -133,22 +133,51 @@ type MediaContent struct { ...@@ -133,22 +133,51 @@ type MediaContent struct {
func (m *MediaContent) GetImageMedia() *MessageImageUrl { func (m *MediaContent) GetImageMedia() *MessageImageUrl {
if m.ImageUrl != nil { if m.ImageUrl != nil {
if _, ok := m.ImageUrl.(*MessageImageUrl); ok {
return m.ImageUrl.(*MessageImageUrl) 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 return nil
} }
func (m *MediaContent) GetInputAudio() *MessageInputAudio { func (m *MediaContent) GetInputAudio() *MessageInputAudio {
if m.InputAudio != nil { if m.InputAudio != nil {
if _, ok := m.InputAudio.(*MessageInputAudio); ok {
return m.InputAudio.(*MessageInputAudio) 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 return nil
} }
func (m *MediaContent) GetFile() *MessageFile { func (m *MediaContent) GetFile() *MessageFile {
if m.File != nil { if m.File != nil {
if _, ok := m.File.(*MessageFile); ok {
return m.File.(*MessageFile) 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 return nil
} }
...@@ -213,6 +242,186 @@ func (m *Message) SetToolCalls(toolCalls any) { ...@@ -213,6 +242,186 @@ func (m *Message) SetToolCalls(toolCalls any) {
} }
func (m *Message) StringContent() string { 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 { if m.parsedStringContent != nil {
return *m.parsedStringContent return *m.parsedStringContent
} }
...@@ -383,7 +592,7 @@ func (m *Message) ParseContent() []MediaContent { ...@@ -383,7 +592,7 @@ func (m *Message) ParseContent() []MediaContent {
m.parsedContent = contentList m.parsedContent = contentList
} }
return contentList return contentList
} }*/
type WebSearchOptions struct { type WebSearchOptions struct {
SearchContextSize string `json:"search_context_size,omitempty"` SearchContextSize string `json:"search_context_size,omitempty"`
......
...@@ -11,7 +11,6 @@ require ( ...@@ -11,7 +11,6 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 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/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b 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/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/sessions v0.0.5
...@@ -25,10 +24,10 @@ require ( ...@@ -25,10 +24,10 @@ require (
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pkg/errors v0.9.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/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0 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/crypto v0.35.0
golang.org/x/image v0.23.0 golang.org/x/image v0.23.0
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
...@@ -43,12 +42,13 @@ require ( ...@@ -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/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.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/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/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
......
...@@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c ...@@ -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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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 ...@@ -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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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 ...@@ -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.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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
......
...@@ -7,7 +7,7 @@ all: build-frontend start-backend ...@@ -7,7 +7,7 @@ all: build-frontend start-backend
build-frontend: build-frontend:
@echo "Building 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: start-backend:
@echo "Starting backend dev server..." @echo "Starting backend dev server..."
......
...@@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) { ...@@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) {
if skKey != "" { if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+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") key := c.Request.Header.Get("Authorization")
parts := make([]string, 0) parts := make([]string, 0)
......
package middleware
import (
"sync/atomic"
"github.com/gin-gonic/gin"
)
// HTTPStats 存储HTTP统计信息
type HTTPStats struct {
activeConnections int64
}
var globalStats = &HTTPStats{}
// StatsMiddleware 统计中间件
func StatsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 增加活跃连接数
atomic.AddInt64(&globalStats.activeConnections, 1)
// 确保在请求结束时减少连接数
defer func() {
atomic.AddInt64(&globalStats.activeConnections, -1)
}()
c.Next()
}
}
// StatsInfo 统计信息结构
type StatsInfo struct {
ActiveConnections int64 `json:"active_connections"`
}
// GetStats 获取统计信息
func GetStats() StatsInfo {
return StatsInfo{
ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
}
}
\ No newline at end of file
...@@ -122,6 +122,9 @@ func InitOptionMap() { ...@@ -122,6 +122,9 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["ApiInfo"] = ""
common.OptionMap["UptimeKumaUrl"] = ""
common.OptionMap["UptimeKumaSlug"] = ""
// 自动添加所有注册的模型配置 // 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs() modelConfigs := config.GlobalConfig.ExportAllConfigs()
......
...@@ -19,7 +19,7 @@ func cacheSetToken(token Token) error { ...@@ -19,7 +19,7 @@ func cacheSetToken(token Token) error {
func cacheDeleteToken(key string) error { func cacheDeleteToken(key string) error {
key = common.GenerateHMAC(key) key = common.GenerateHMAC(key)
err := common.RedisHDelObj(fmt.Sprintf("token:%s", key)) err := common.RedisDelKey(fmt.Sprintf("token:%s", key))
if err != nil { if err != nil {
return err return err
} }
......
...@@ -3,11 +3,12 @@ package model ...@@ -3,11 +3,12 @@ package model
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"one-api/common" "one-api/common"
"one-api/constant" "one-api/constant"
"time" "time"
"github.com/gin-gonic/gin"
"github.com/bytedance/gopkg/util/gopool" "github.com/bytedance/gopkg/util/gopool"
) )
...@@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error { ...@@ -57,7 +58,7 @@ func invalidateUserCache(userId int) error {
if !common.RedisEnabled { if !common.RedisEnabled {
return nil return nil
} }
return common.RedisHDelObj(getUserCacheKey(userId)) return common.RedisDelKey(getUserCacheKey(userId))
} }
// updateUserCache updates all user cache fields using hash // updateUserCache updates all user cache fields using hash
......
...@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeEmbeddings: case constant.RelayModeEmbeddings:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl) 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: case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
case constant.RelayModeCompletions: case constant.RelayModeCompletions:
...@@ -76,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf ...@@ -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) { 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) { 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 ...@@ -103,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
err, usage = aliImageHandler(c, resp, info) err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeEmbeddings: case constant.RelayModeEmbeddings:
err, usage = aliEmbeddingHandler(c, resp) err, usage = aliEmbeddingHandler(c, resp)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default: default:
if info.IsStream { if info.IsStream {
err, usage = openai.OaiStreamHandler(c, resp, info) err, usage = openai.OaiStreamHandler(c, resp, info)
......
...@@ -8,6 +8,7 @@ var ModelList = []string{ ...@@ -8,6 +8,7 @@ var ModelList = []string{
"qwq-32b", "qwq-32b",
"qwen3-235b-a22b", "qwen3-235b-a22b",
"text-embedding-v1", "text-embedding-v1",
"gte-rerank-v2",
} }
var ChannelName = "ali" var ChannelName = "ali"
package ali package ali
import "one-api/dto"
type AliMessage struct { type AliMessage struct {
Content string `json:"content"` Content string `json:"content"`
Role string `json:"role"` Role string `json:"role"`
...@@ -97,3 +99,28 @@ type AliImageRequest struct { ...@@ -97,3 +99,28 @@ type AliImageRequest struct {
} `json:"parameters,omitempty"` } `json:"parameters,omitempty"`
ResponseFormat string `json:"response_format,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
}
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
}
...@@ -3,7 +3,6 @@ package ali ...@@ -3,7 +3,6 @@ package ali
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"github.com/gin-gonic/gin"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
...@@ -11,6 +10,8 @@ import ( ...@@ -11,6 +10,8 @@ import (
"one-api/relay/helper" "one-api/relay/helper"
"one-api/service" "one-api/service"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r // 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 ...@@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
} }
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest { func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
if request.Model == "" {
request.Model = "text-embedding-v1"
}
return &AliEmbeddingRequest{ return &AliEmbeddingRequest{
Model: request.Model, Model: request.Model,
Input: struct { Input: struct {
...@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW ...@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
}, nil }, nil
} }
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse) model := c.GetString("model")
if model == "" {
model = "text-embedding-v4"
}
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model)
jsonResponse, err := json.Marshal(fullTextResponse) jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil { if err != nil {
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), 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 ...@@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
return nil, &fullTextResponse.Usage return nil, &fullTextResponse.Usage
} }
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse { func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
Object: "list", Object: "list",
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)), Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
Model: "text-embedding-v1", Model: model,
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens}, Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
} }
...@@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe ...@@ -94,12 +96,11 @@ func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbe
} }
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Output.Text)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Output.Text,
}, },
FinishReason: response.Output.FinishReason, FinishReason: response.Output.FinishReason,
} }
......
...@@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc ...@@ -109,6 +109,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
gopool.Go(func() { gopool.Go(func() {
defer 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 { if common2.DebugEnabled {
println("SSE ping goroutine stopped.") println("SSE ping goroutine stopped.")
} }
...@@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc ...@@ -119,19 +125,32 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
} }
ticker := time.NewTicker(pingInterval) ticker := time.NewTicker(pingInterval)
// 退出时清理 ticker // 确保在任何情况下都清理ticker
defer ticker.Stop() defer func() {
ticker.Stop()
if common2.DebugEnabled {
println("SSE ping ticker stopped")
}
}()
var pingMutex sync.Mutex var pingMutex sync.Mutex
if common2.DebugEnabled { if common2.DebugEnabled {
println("SSE ping goroutine started") println("SSE ping goroutine started")
} }
// 增加超时控制,防止goroutine长时间运行
maxPingDuration := 120 * time.Minute // 最大ping持续时间
pingTimeout := time.NewTimer(maxPingDuration)
defer pingTimeout.Stop()
for { for {
select { select {
// 发送 ping 数据 // 发送 ping 数据
case <-ticker.C: case <-ticker.C:
if err := sendPingData(c, &pingMutex); err != nil { if err := sendPingData(c, &pingMutex); err != nil {
if common2.DebugEnabled {
println("SSE ping error, stopping goroutine:", err.Error())
}
return return
} }
// 收到退出信号 // 收到退出信号
...@@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc ...@@ -140,6 +159,12 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
// request 结束 // request 结束
case <-c.Request.Context().Done(): case <-c.Request.Context().Done():
return 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 ...@@ -148,19 +173,34 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
} }
func sendPingData(c *gin.Context, mutex *sync.Mutex) error { func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
// 增加超时控制,防止锁死等待
done := make(chan error, 1)
go func() {
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
err := helper.PingData(c) err := helper.PingData(c)
if err != nil { if err != nil {
common2.LogError(c, "SSE ping error: "+err.Error()) common2.LogError(c, "SSE ping error: "+err.Error())
return err done <- err
return
} }
if common2.DebugEnabled { if common2.DebugEnabled {
println("SSE ping data sent.") println("SSE ping data sent.")
} }
return nil 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")
}
} }
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { 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 ...@@ -175,15 +215,23 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
client = service.GetHttpClient() client = service.GetHttpClient()
} }
var stopPinger context.CancelFunc
if info.IsStream { if info.IsStream {
helper.SetEventStreamHeaders(c) helper.SetEventStreamHeaders(c)
// 处理流式请求的 ping 保活 // 处理流式请求的 ping 保活
generalSettings := operation_setting.GetGeneralSetting() generalSettings := operation_setting.GetGeneralSetting()
if generalSettings.PingIntervalEnabled { if generalSettings.PingIntervalEnabled {
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
stopPinger := startPingKeepAlive(c, pingInterval) stopPinger = startPingKeepAlive(c, pingInterval)
defer stopPinger() // 使用defer确保在任何情况下都能停止ping goroutine
defer func() {
if stopPinger != nil {
stopPinger()
if common2.DebugEnabled {
println("SSE ping goroutine stopped by defer")
}
}
}()
} }
} }
......
...@@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { ...@@ -53,12 +53,11 @@ func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest {
} }
func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse {
content, _ := json.Marshal(response.Result)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Result,
}, },
FinishReason: "stop", FinishReason: "stop",
} }
......
...@@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla ...@@ -48,9 +48,9 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
prompt := "" prompt := ""
for _, message := range textRequest.Messages { for _, message := range textRequest.Messages {
if message.Role == "user" { 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" { } 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" { } else if message.Role == "system" {
if prompt == "" { if prompt == "" {
prompt = message.StringContent() prompt = message.StringContent()
...@@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla ...@@ -155,15 +155,13 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
} }
if lastMessage.Role == message.Role && lastMessage.Role != "tool" { if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() { if lastMessage.IsStringContent() && message.IsStringContent() {
content, _ := json.Marshal(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
fmtMessage.Content = content
// delete last message // delete last message
formatMessages = formatMessages[:len(formatMessages)-1] formatMessages = formatMessages[:len(formatMessages)-1]
} }
} }
if fmtMessage.Content == nil { if fmtMessage.Content == nil {
content, _ := json.Marshal("...") fmtMessage.SetStringContent("...")
fmtMessage.Content = content
} }
formatMessages = append(formatMessages, fmtMessage) formatMessages = append(formatMessages, fmtMessage)
lastMessage = fmtMessage lastMessage = fmtMessage
...@@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto ...@@ -397,12 +395,11 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
thinkingContent := "" thinkingContent := ""
if reqMode == RequestModeCompletion { if reqMode == RequestModeCompletion {
content, _ := json.Marshal(strings.TrimPrefix(claudeResponse.Completion, " "))
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: strings.TrimPrefix(claudeResponse.Completion, " "),
Name: nil, Name: nil,
}, },
FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),
......
...@@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt ...@@ -195,11 +195,10 @@ func cohereHandler(c *gin.Context, resp *http.Response, modelName string, prompt
openaiResp.Model = modelName openaiResp.Model = modelName
openaiResp.Usage = usage openaiResp.Usage = usage
content, _ := json.Marshal(cohereResp.Text)
openaiResp.Choices = []dto.OpenAITextResponseChoice{ openaiResp.Choices = []dto.OpenAITextResponseChoice{
{ {
Index: 0, Index: 0,
Message: dto.Message{Content: content, Role: "assistant"}, Message: dto.Message{Content: cohereResp.Text, Role: "assistant"},
FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason),
}, },
} }
......
...@@ -10,7 +10,7 @@ type CozeError struct { ...@@ -10,7 +10,7 @@ type CozeError struct {
type CozeEnterMessage struct { type CozeEnterMessage struct {
Role string `json:"role"` Role string `json:"role"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Content json.RawMessage `json:"content,omitempty"` Content any `json:"content,omitempty"`
MetaData json.RawMessage `json:"meta_data,omitempty"` MetaData json.RawMessage `json:"meta_data,omitempty"`
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
} }
......
...@@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf ...@@ -278,12 +278,11 @@ func difyHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInf
Created: common.GetTimestamp(), Created: common.GetTimestamp(),
Usage: difyResponse.MetaData.Usage, Usage: difyResponse.MetaData.Usage,
} }
content, _ := json.Marshal(difyResponse.Answer)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: difyResponse.Answer,
}, },
FinishReason: "stop", FinishReason: "stop",
} }
......
package gemini package gemini
import "encoding/json"
type GeminiChatRequest struct { type GeminiChatRequest struct {
Contents []GeminiChatContent `json:"contents"` Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
...@@ -22,19 +24,38 @@ type GeminiInlineData struct { ...@@ -22,19 +24,38 @@ type GeminiInlineData struct {
Data string `json:"data"` 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 { type FunctionCall struct {
FunctionName string `json:"name"` FunctionName string `json:"name"`
Arguments any `json:"args"` Arguments any `json:"args"`
} }
type GeminiFunctionResponseContent struct {
Name string `json:"name"`
Content any `json:"content"`
}
type FunctionResponse struct { type FunctionResponse struct {
Name string `json:"name"` Name string `json:"name"`
Response GeminiFunctionResponseContent `json:"response"` Response map[string]interface{} `json:"response"`
} }
type GeminiPartExecutableCode struct { type GeminiPartExecutableCode struct {
...@@ -63,6 +84,33 @@ type GeminiPart struct { ...@@ -63,6 +84,33 @@ type GeminiPart struct {
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` 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 { type GeminiChatContent struct {
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
Parts []GeminiPart `json:"parts"` Parts []GeminiPart `json:"parts"`
...@@ -121,6 +169,12 @@ type GeminiUsageMetadata struct { ...@@ -121,6 +169,12 @@ type GeminiUsageMetadata struct {
CandidatesTokenCount int `json:"candidatesTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"` TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"` ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
} }
// Imagen related structs // Imagen related structs
......
...@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela ...@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, 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 响应 // 直接返回 Gemini 原生格式的 JSON 响应
jsonResponse, err := json.Marshal(geminiResponse) jsonResponse, err := json.Marshal(geminiResponse)
if err != nil { if err != nil {
...@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info ...@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount 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 响应 // 直接发送 GeminiChatResponse 响应
...@@ -118,11 +136,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info ...@@ -118,11 +136,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
} }
// 计算最终使用量 // 计算最终使用量
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 结束流式响应 // 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
helper.Done(c) //helper.Done(c)
return usage, nil return usage, nil
} }
...@@ -58,27 +58,65 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -58,27 +58,65 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if strings.HasSuffix(info.OriginModelName, "-thinking") { if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget // 硬编码不支持 ThinkingBudget 的旧模型
if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") { 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{ geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true, IncludeThoughts: true,
} }
} else { } else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) 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 { if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576 budgetTokens = 24576
} }
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)), ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true, IncludeThoughts: true,
} }
} }
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") { } else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
// 检查是否为新的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{ geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0), ThinkingBudget: common.GetPointer(0),
} }
} }
} }
}
safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList))
for _, category := range SafetySettingList { for _, category := range SafetySettingList {
...@@ -137,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -137,12 +175,6 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
// json_data, _ := json.Marshal(geminiRequest.Tools) // json_data, _ := json.Marshal(geminiRequest.Tools)
// common.SysLog("tools_json: " + string(json_data)) // 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") { 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 ...@@ -173,17 +205,27 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
} else if val, exists := tool_call_ids[message.ToolCallId]; exists { } else if val, exists := tool_call_ids[message.ToolCallId]; exists {
name = val 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{ functionResp := &FunctionResponse{
Name: name, Name: name,
Response: GeminiFunctionResponseContent{ Response: contentMap,
Name: name,
Content: content,
},
}
if content == nil {
functionResp.Response.Content = message.StringContent()
} }
*parts = append(*parts, GeminiPart{ *parts = append(*parts, GeminiPart{
FunctionResponse: functionResp, FunctionResponse: functionResp,
}) })
...@@ -280,13 +322,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -280,13 +322,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if part.GetInputAudio().Data == "" { if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini") 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 { if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
} }
parts = append(parts, GeminiPart{ parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{ InlineData: &GeminiInlineData{
MimeType: format, MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String, Data: base64String,
}, },
}) })
...@@ -576,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp ...@@ -576,14 +618,13 @@ func responseGeminiChat2OpenAI(response *GeminiChatResponse) *dto.OpenAITextResp
Created: common.GetTimestamp(), Created: common.GetTimestamp(),
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
} }
content, _ := json.Marshal("")
isToolCall := false isToolCall := false
for _, candidate := range response.Candidates { for _, candidate := range response.Candidates {
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: int(candidate.Index), Index: int(candidate.Index),
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: "",
}, },
FinishReason: constant.FinishReasonStop, FinishReason: constant.FinishReasonStop,
} }
...@@ -738,6 +779,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom ...@@ -738,6 +779,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount 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) err = helper.ObjectData(c, response)
if err != nil { if err != nil {
...@@ -812,6 +860,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re ...@@ -812,6 +860,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens 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 fullTextResponse.Usage = usage
jsonResponse, err := json.Marshal(fullTextResponse) jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil { if err != nil {
......
package mistral package mistral
import ( import (
"one-api/common"
"one-api/dto" "one-api/dto"
"regexp"
) )
var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$")
func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
messages := make([]dto.Message, 0, len(request.Messages)) messages := make([]dto.Message, 0, len(request.Messages))
idMap := make(map[string]string)
for _, message := range request.Messages { 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() mediaMessages := message.ParseContent()
if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" {
mediaMessages = []dto.MediaContent{}
}
for j, mediaMessage := range mediaMessages { for j, mediaMessage := range mediaMessages {
if mediaMessage.Type == dto.ContentTypeImageURL { if mediaMessage.Type == dto.ContentTypeImageURL {
imageUrl := mediaMessage.GetImageMedia() imageUrl := mediaMessage.GetImageMedia()
......
...@@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { ...@@ -45,12 +45,11 @@ func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse {
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
} }
for i, candidate := range response.Candidates { for i, candidate := range response.Candidates {
content, _ := json.Marshal(candidate.Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: i, Index: i,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: candidate.Content,
}, },
FinishReason: "stop", FinishReason: "stop",
} }
......
...@@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon ...@@ -56,12 +56,11 @@ func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextRespon
}, },
} }
if len(response.Choices) > 0 { if len(response.Choices) > 0 {
content, _ := json.Marshal(response.Choices[0].Messages.Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Choices[0].Messages.Content,
}, },
FinishReason: response.Choices[0].FinishReason, FinishReason: response.Choices[0].FinishReason,
} }
......
...@@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse ...@@ -61,12 +61,11 @@ func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse
}, },
} }
} }
content, _ := json.Marshal(response.Payload.Choices.Text[0].Content)
choice := dto.OpenAITextResponseChoice{ choice := dto.OpenAITextResponseChoice{
Index: 0, Index: 0,
Message: dto.Message{ Message: dto.Message{
Role: "assistant", Role: "assistant",
Content: content, Content: response.Payload.Choices.Text[0].Content,
}, },
FinishReason: constant.FinishReasonStop, FinishReason: constant.FinishReasonStop,
} }
......
...@@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { ...@@ -108,12 +108,11 @@ func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse {
Usage: response.Data.Usage, Usage: response.Data.Usage,
} }
for i, choice := range response.Data.Choices { for i, choice := range response.Data.Choices {
content, _ := json.Marshal(strings.Trim(choice.Content, "\""))
openaiChoice := dto.OpenAITextResponseChoice{ openaiChoice := dto.OpenAITextResponseChoice{
Index: i, Index: i,
Message: dto.Message{ Message: dto.Message{
Role: choice.Role, Role: choice.Role,
Content: content, Content: strings.Trim(choice.Content, "\""),
}, },
FinishReason: "", FinishReason: "",
} }
......
...@@ -3,6 +3,7 @@ package helper ...@@ -3,6 +3,7 @@ package helper
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"one-api/common" "one-api/common"
...@@ -19,7 +20,7 @@ import ( ...@@ -19,7 +20,7 @@ import (
) )
const ( const (
InitialScannerBufferSize = 1 << 20 // 1MB (1*1024*1024) InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024)
DefaultPingInterval = 10 * time.Second DefaultPingInterval = 10 * time.Second
) )
...@@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ...@@ -30,7 +31,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
return return
} }
defer resp.Body.Close() // 确保响应体总是被关闭
defer func() {
if resp.Body != nil {
resp.Body.Close()
}
}()
streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
if strings.HasPrefix(info.UpstreamModelName, "o") { if strings.HasPrefix(info.UpstreamModelName, "o") {
...@@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ...@@ -39,11 +45,12 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
} }
var ( var (
stopChan = make(chan bool, 2) stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
scanner = bufio.NewScanner(resp.Body) scanner = bufio.NewScanner(resp.Body)
ticker = time.NewTicker(streamingTimeout) ticker = time.NewTicker(streamingTimeout)
pingTicker *time.Ticker pingTicker *time.Ticker
writeMutex sync.Mutex // Mutex to protect concurrent writes writeMutex sync.Mutex // Mutex to protect concurrent writes
wg sync.WaitGroup // 用于等待所有 goroutine 退出
) )
generalSettings := operation_setting.GetGeneralSetting() generalSettings := operation_setting.GetGeneralSetting()
...@@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ...@@ -57,13 +64,32 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
pingTicker = time.NewTicker(pingInterval) pingTicker = time.NewTicker(pingInterval)
} }
// 改进资源清理,确保所有 goroutine 正确退出
defer func() { defer func() {
// 通知所有 goroutine 停止
common.SafeSendBool(stopChan, true)
ticker.Stop() ticker.Stop()
if pingTicker != nil { if pingTicker != nil {
pingTicker.Stop() 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) close(stopChan)
}() }()
scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize) scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize)
scanner.Split(bufio.ScanLines) scanner.Split(bufio.ScanLines)
SetEventStreamHeaders(c) SetEventStreamHeaders(c)
...@@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ...@@ -73,35 +99,95 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
ctx = context.WithValue(ctx, "stop_chan", stopChan) ctx = context.WithValue(ctx, "stop_chan", stopChan)
// Handle ping data sending // Handle ping data sending with improved error handling
if pingEnabled && pingTicker != nil { if pingEnabled && pingTicker != nil {
wg.Add(1)
gopool.Go(func() { 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 { for {
select { select {
case <-pingTicker.C: case <-pingTicker.C:
writeMutex.Lock() // Lock before writing // 使用超时机制防止写操作阻塞
err := PingData(c) done := make(chan error, 1)
writeMutex.Unlock() // Unlock after writing go func() {
writeMutex.Lock()
defer writeMutex.Unlock()
done <- PingData(c)
}()
select {
case err := <-done:
if err != nil { if err != nil {
common.LogError(c, "ping data error: "+err.Error()) common.LogError(c, "ping data error: "+err.Error())
common.SafeSendBool(stopChan, true)
return return
} }
if common.DebugEnabled { if common.DebugEnabled {
println("ping data sent") println("ping data sent")
} }
case <-time.After(10 * time.Second):
common.LogError(c, "ping data send timeout")
return
case <-ctx.Done(): case <-ctx.Done():
if common.DebugEnabled { return
println("ping data goroutine stopped") case <-stopChan:
return
} }
case <-ctx.Done():
return
case <-stopChan:
return
case <-c.Request.Context().Done():
// 监听客户端断开连接
return
case <-pingTimeout.C:
common.LogError(c, "ping goroutine max duration reached")
return return
} }
} }
}) })
} }
// Scanner goroutine with improved error handling
wg.Add(1)
common.RelayCtxGo(ctx, func() { 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() { for scanner.Scan() {
// 检查是否需要停止
select {
case <-stopChan:
return
case <-ctx.Done():
return
case <-c.Request.Context().Done():
return
default:
}
ticker.Reset(streamingTimeout) ticker.Reset(streamingTimeout)
data := scanner.Text() data := scanner.Text()
if common.DebugEnabled { if common.DebugEnabled {
...@@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon ...@@ -119,11 +205,27 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
data = strings.TrimSuffix(data, "\r") data = strings.TrimSuffix(data, "\r")
if !strings.HasPrefix(data, "[DONE]") { if !strings.HasPrefix(data, "[DONE]") {
info.SetFirstResponseTime() info.SetFirstResponseTime()
writeMutex.Lock() // Lock before writing
success := dataHandler(data) // 使用超时机制防止写操作阻塞
writeMutex.Unlock() // Unlock after writing done := make(chan bool, 1)
go func() {
writeMutex.Lock()
defer writeMutex.Unlock()
done <- dataHandler(data)
}()
select {
case success := <-done:
if !success { if !success {
break 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 ...@@ -133,17 +235,18 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
common.LogError(c, "scanner error: "+err.Error()) common.LogError(c, "scanner error: "+err.Error())
} }
} }
common.SafeSendBool(stopChan, true)
}) })
// 主循环等待完成或超时
select { select {
case <-ticker.C: case <-ticker.C:
// 超时处理逻辑 // 超时处理逻辑
common.LogError(c, "streaming timeout") common.LogError(c, "streaming timeout")
common.SafeSendBool(stopChan, true)
case <-stopChan: case <-stopChan:
// 正常结束 // 正常结束
common.LogInfo(c, "streaming finished") common.LogInfo(c, "streaming finished")
case <-c.Request.Context().Done():
// 客户端断开连接
common.LogInfo(c, "client disconnected")
} }
} }
...@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
promptTokens := usage.PromptTokens promptTokens := usage.PromptTokens
cacheTokens := usage.PromptTokensDetails.CachedTokens cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens completionTokens := usage.CompletionTokens
modelName := relayInfo.OriginModelName modelName := relayInfo.OriginModelName
...@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dPromptTokens := decimal.NewFromInt(int64(promptTokens)) dPromptTokens := decimal.NewFromInt(int64(promptTokens))
dCacheTokens := decimal.NewFromInt(int64(cacheTokens)) dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
dImageTokens := decimal.NewFromInt(int64(imageTokens)) dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio) dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio) dCacheRatio := decimal.NewFromFloat(cacheRatio)
...@@ -412,22 +414,42 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -412,22 +414,42 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) 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()) fileSearchTool.CallCount, dFileSearchQuota.String())
} }
} }
var quotaCalculateDecimal decimal.Decimal var quotaCalculateDecimal decimal.Decimal
var audioInputQuota decimal.Decimal
var audioInputPrice float64
if !priceData.UsePrice { if !priceData.UsePrice {
nonCachedTokens := dPromptTokens.Sub(dCacheTokens) baseTokens := dPromptTokens
cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio) // 减去 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)
}
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio) // 减去 Gemini audio tokens
if imageTokens > 0 { if !dAudioTokens.IsZero() {
nonImageTokens := dPromptTokens.Sub(dImageTokens) audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
imageTokensWithRatio := dImageTokens.Mul(dImageRatio) if audioInputPrice > 0 {
promptQuota = nonImageTokens.Add(imageTokensWithRatio) // 重新计算 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) completionQuota := dCompletionTokens.Mul(dCompletionRatio)
...@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
// 添加 responses tools call 调用的配额 // 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart()) quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens totalTokens := promptTokens + completionTokens
...@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other["file_search_price"] = fileSearchPrice 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, model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other) tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
} }
...@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/setup", controller.GetSetup) apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", controller.PostSetup) apiRouter.POST("/setup", controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
apiRouter.GET("/notice", controller.GetNotice) apiRouter.GET("/notice", controller.GetNotice)
...@@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -105,6 +106,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
channelRoute.POST("/fetch_models", controller.FetchModels) channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
} }
tokenRoute := apiRouter.Group("/token") tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth()) tokenRoute.Use(middleware.UserAuth())
......
...@@ -11,6 +11,7 @@ import ( ...@@ -11,6 +11,7 @@ import (
func SetRelayRouter(router *gin.Engine) { func SetRelayRouter(router *gin.Engine) {
router.Use(middleware.CORS()) router.Use(middleware.CORS())
router.Use(middleware.DecompressRequestMiddleware()) router.Use(middleware.DecompressRequestMiddleware())
router.Use(middleware.StatsMiddleware())
// https://platform.openai.com/docs/api-reference/introduction // https://platform.openai.com/docs/api-reference/introduction
modelsRouter := router.Group("/v1/models") modelsRouter := router.Group("/v1/models")
modelsRouter.Use(middleware.TokenAuth()) modelsRouter.Use(middleware.TokenAuth())
......
...@@ -3,6 +3,7 @@ package service ...@@ -3,6 +3,7 @@ package service
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strings"
) )
func parseAudio(audioBase64 string, format string) (duration float64, err error) { func parseAudio(audioBase64 string, format string) (duration float64, err error) {
...@@ -29,3 +30,19 @@ 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) duration = float64(samplesCount) / float64(sampleRate)
return duration, nil 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
}
...@@ -4,6 +4,8 @@ import ( ...@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/tiktoken-go/tokenizer"
"github.com/tiktoken-go/tokenizer/codec"
"image" "image"
"log" "log"
"math" "math"
...@@ -11,78 +13,63 @@ import ( ...@@ -11,78 +13,63 @@ import (
"one-api/constant" "one-api/constant"
"one-api/dto" "one-api/dto"
relaycommon "one-api/relay/common" relaycommon "one-api/relay/common"
"one-api/setting/operation_setting"
"strings" "strings"
"sync"
"unicode/utf8" "unicode/utf8"
"github.com/pkoukk/tiktoken-go"
) )
// tokenEncoderMap won't grow after initialization // tokenEncoderMap won't grow after initialization
var tokenEncoderMap = map[string]*tiktoken.Tiktoken{} var defaultTokenEncoder tokenizer.Codec
var defaultTokenEncoder *tiktoken.Tiktoken
var o200kTokenEncoder *tiktoken.Tiktoken // 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() { func InitTokenEncoders() {
common.SysLog("initializing token encoders") common.SysLog("initializing token encoders")
cl100TokenEncoder, err := tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE) defaultTokenEncoder = codec.NewCl100kBase()
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
}
}
common.SysLog("token encoders initialized") common.SysLog("token encoders initialized")
} }
func getModelDefaultTokenEncoder(model string) *tiktoken.Tiktoken { func getTokenEncoder(model string) tokenizer.Codec {
if strings.HasPrefix(model, "gpt-4o") || strings.HasPrefix(model, "chatgpt-4o") || strings.HasPrefix(model, "o1") { // First, try to get the encoder from cache with read lock
return o200kTokenEncoder tokenEncoderMutex.RLock()
if encoder, exists := tokenEncoderMap[model]; exists {
tokenEncoderMutex.RUnlock()
return encoder
} }
return defaultTokenEncoder tokenEncoderMutex.RUnlock()
}
func getTokenEncoder(model string) *tiktoken.Tiktoken { // If not in cache, create new encoder with write lock
tokenEncoder, ok := tokenEncoderMap[model] tokenEncoderMutex.Lock()
if ok && tokenEncoder != nil { defer tokenEncoderMutex.Unlock()
return tokenEncoder
// Double-check if another goroutine already created the encoder
if encoder, exists := tokenEncoderMap[model]; exists {
return encoder
} }
// 如果ok(即model在tokenEncoderMap中),但是tokenEncoder为nil,说明可能是自定义模型
if ok { // Create new encoder
tokenEncoder, err := tiktoken.EncodingForModel(model) modelCodec, err := tokenizer.ForModel(tokenizer.Model(model))
if err != nil { 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())) // Cache the default encoder for this model to avoid repeated failures
tokenEncoder = getModelDefaultTokenEncoder(model) tokenEncoderMap[model] = defaultTokenEncoder
} return defaultTokenEncoder
tokenEncoderMap[model] = tokenEncoder
return tokenEncoder
} }
// 如果model不在tokenEncoderMap中,直接返回默认的tokenEncoder
return getModelDefaultTokenEncoder(model) // Cache the new encoder
tokenEncoderMap[model] = modelCodec
return modelCodec
} }
func getTokenNum(tokenEncoder *tiktoken.Tiktoken, text string) int { func getTokenNum(tokenEncoder tokenizer.Codec, text string) int {
if text == "" { if text == "" {
return 0 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) { func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) {
...@@ -261,16 +248,20 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream ...@@ -261,16 +248,20 @@ func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream
//} //}
tokenNum += 1000 tokenNum += 1000
case "tool_use": case "tool_use":
if mediaMessage.Input != nil {
tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name)
inputJSON, _ := json.Marshal(mediaMessage.Input) inputJSON, _ := json.Marshal(mediaMessage.Input)
tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) tokenNum += getTokenNum(tokenEncoder, string(inputJSON))
}
case "tool_result": case "tool_result":
if mediaMessage.Content != nil {
contentJSON, _ := json.Marshal(mediaMessage.Content) contentJSON, _ := json.Marshal(mediaMessage.Content)
tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) tokenNum += getTokenNum(tokenEncoder, string(contentJSON))
} }
} }
} }
} }
}
// Add a constant for message formatting (this may need adjustment based on Claude's exact formatting) // Add a constant for message formatting (this may need adjustment based on Claude's exact formatting)
tokenNum += len(messages) * 2 // Assuming 2 tokens per message for formatting tokenNum += len(messages) * 2 // Assuming 2 tokens per message for formatting
...@@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod ...@@ -386,7 +377,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod
for _, message := range messages { for _, message := range messages {
tokenNum += tokensPerMessage tokenNum += tokensPerMessage
tokenNum += getTokenNum(tokenEncoder, message.Role) tokenNum += getTokenNum(tokenEncoder, message.Role)
if len(message.Content) > 0 { if message.Content != nil {
if message.Name != nil { if message.Name != nil {
tokenNum += tokensPerName tokenNum += tokensPerName
tokenNum += getTokenNum(tokenEncoder, *message.Name) tokenNum += getTokenNum(tokenEncoder, *message.Name)
......
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
}
\ No newline at end of file
...@@ -14,6 +14,13 @@ const ( ...@@ -14,6 +14,13 @@ const (
FileSearchPrice = 2.5 FileSearchPrice = 2.5
) )
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
)
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
// 确定模型类型 // 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
...@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 ...@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
func GetFileSearchPricePerThousand() float64 { func GetFileSearchPricePerThousand() float64 {
return FileSearchPrice 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
}
No preview for this file type
...@@ -6,27 +6,42 @@ ...@@ -6,27 +6,42 @@
"dependencies": { "dependencies": {
"@douyinfe/semi-icons": "^2.63.1", "@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1", "@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
"@visactor/react-vchart": "~1.8.8", "@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8", "@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8",
"axios": "^0.27.2", "axios": "^0.27.2",
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.19",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"history": "^5.3.0", "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", "marked": "^4.1.1",
"mermaid": "^11.6.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4", "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-router-dom": "^6.3.0",
"react-telegram-login": "^1.1.2", "react-telegram-login": "^1.1.2",
"react-toastify": "^9.0.8", "react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5", "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-offline": "^2.5.0",
"semantic-ui-react": "^2.1.3", "semantic-ui-react": "^2.1.3",
"sse": "https://github.com/mpetazzoni/sse.js", "sse.js": "^2.6.0",
"i18next": "^23.16.8", "unist-util-visit": "^5.0.0",
"react-i18next": "^13.0.0", "use-debounce": "^10.0.4"
"i18next-browser-languagedetector": "^7.2.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
...@@ -54,9 +69,13 @@ ...@@ -54,9 +69,13 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@so1ve/prettier-config": "^3.1.0", "@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"tailwindcss": "^3",
"typescript": "4.4.2", "typescript": "4.4.2",
"vite": "^5.2.0" "vite": "^5.2.0"
}, },
......
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
This diff was suppressed by a .gitattributes entry.
This diff was suppressed by a .gitattributes entry.
import React, { lazy, Suspense, useContext, useEffect } from 'react'; import React, { lazy, Suspense } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom'; 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 User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute'; import { AuthRedirect, PrivateRoute } from './helpers';
import RegisterForm from './components/RegisterForm'; import RegisterForm from './components/auth/RegisterForm.js';
import LoginForm from './components/LoginForm'; import LoginForm from './components/auth/LoginForm.js';
import NotFound from './pages/NotFound'; import NotFound from './pages/NotFound';
import Setting from './pages/Setting'; import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser'; import EditUser from './pages/User/EditUser';
import PasswordResetForm from './components/PasswordResetForm'; import PasswordResetForm from './components/auth/PasswordResetForm.js';
import PasswordResetConfirm from './components/PasswordResetConfirm'; import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
import Channel from './pages/Channel'; import Channel from './pages/Channel';
import Token from './pages/Token'; import Token from './pages/Token';
import EditChannel from './pages/Channel/EditChannel'; import EditChannel from './pages/Channel/EditChannel';
...@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp'; ...@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log'; import Log from './pages/Log';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
import Chat2Link from './pages/Chat2Link'; import Chat2Link from './pages/Chat2Link';
import { Layout } from '@douyinfe/semi-ui';
import Midjourney from './pages/Midjourney'; import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js'; import Pricing from './pages/Pricing/index.js';
import Task from './pages/Task/index.js'; import Task from './pages/Task/index.js';
import Playground from './pages/Playground/Playground.js'; import Playground from './pages/Playground/index.js';
import OAuth2Callback from './components/OAuth2Callback.js'; import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/PersonalSetting.js'; import PersonalSetting from './components/settings/PersonalSetting.js';
import Setup from './pages/Setup/index.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 Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail')); const Detail = lazy(() => import('./pages/Detail'));
...@@ -55,7 +54,7 @@ function App() { ...@@ -55,7 +54,7 @@ function App() {
} }
/> />
<Route <Route
path='/channel' path='/console/channel'
element={ element={
<PrivateRoute> <PrivateRoute>
<Channel /> <Channel />
...@@ -63,7 +62,7 @@ function App() { ...@@ -63,7 +62,7 @@ function App() {
} }
/> />
<Route <Route
path='/channel/edit/:id' path='/console/channel/edit/:id'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel /> <EditChannel />
...@@ -71,7 +70,7 @@ function App() { ...@@ -71,7 +70,7 @@ function App() {
} }
/> />
<Route <Route
path='/channel/add' path='/console/channel/add'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel /> <EditChannel />
...@@ -79,7 +78,7 @@ function App() { ...@@ -79,7 +78,7 @@ function App() {
} }
/> />
<Route <Route
path='/token' path='/console/token'
element={ element={
<PrivateRoute> <PrivateRoute>
<Token /> <Token />
...@@ -87,7 +86,7 @@ function App() { ...@@ -87,7 +86,7 @@ function App() {
} }
/> />
<Route <Route
path='/playground' path='/console/playground'
element={ element={
<PrivateRoute> <PrivateRoute>
<Playground /> <Playground />
...@@ -95,7 +94,7 @@ function App() { ...@@ -95,7 +94,7 @@ function App() {
} }
/> />
<Route <Route
path='/redemption' path='/console/redemption'
element={ element={
<PrivateRoute> <PrivateRoute>
<Redemption /> <Redemption />
...@@ -103,7 +102,7 @@ function App() { ...@@ -103,7 +102,7 @@ function App() {
} }
/> />
<Route <Route
path='/user' path='/console/user'
element={ element={
<PrivateRoute> <PrivateRoute>
<User /> <User />
...@@ -111,7 +110,7 @@ function App() { ...@@ -111,7 +110,7 @@ function App() {
} }
/> />
<Route <Route
path='/user/edit/:id' path='/console/user/edit/:id'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser /> <EditUser />
...@@ -119,7 +118,7 @@ function App() { ...@@ -119,7 +118,7 @@ function App() {
} }
/> />
<Route <Route
path='/user/edit' path='/console/user/edit'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser /> <EditUser />
...@@ -138,7 +137,9 @@ function App() { ...@@ -138,7 +137,9 @@ function App() {
path='/login' path='/login'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<AuthRedirect>
<LoginForm /> <LoginForm />
</AuthRedirect>
</Suspense> </Suspense>
} }
/> />
...@@ -146,7 +147,9 @@ function App() { ...@@ -146,7 +147,9 @@ function App() {
path='/register' path='/register'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<AuthRedirect>
<RegisterForm /> <RegisterForm />
</AuthRedirect>
</Suspense> </Suspense>
} }
/> />
...@@ -183,7 +186,7 @@ function App() { ...@@ -183,7 +186,7 @@ function App() {
} }
/> />
<Route <Route
path='/setting' path='/console/setting'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -193,7 +196,7 @@ function App() { ...@@ -193,7 +196,7 @@ function App() {
} }
/> />
<Route <Route
path='/personal' path='/console/personal'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -203,7 +206,7 @@ function App() { ...@@ -203,7 +206,7 @@ function App() {
} }
/> />
<Route <Route
path='/topup' path='/console/topup'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -213,7 +216,7 @@ function App() { ...@@ -213,7 +216,7 @@ function App() {
} }
/> />
<Route <Route
path='/log' path='/console/log'
element={ element={
<PrivateRoute> <PrivateRoute>
<Log /> <Log />
...@@ -221,7 +224,7 @@ function App() { ...@@ -221,7 +224,7 @@ function App() {
} }
/> />
<Route <Route
path='/detail' path='/console'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -231,7 +234,7 @@ function App() { ...@@ -231,7 +234,7 @@ function App() {
} }
/> />
<Route <Route
path='/midjourney' path='/console/midjourney'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -241,7 +244,7 @@ function App() { ...@@ -241,7 +244,7 @@ function App() {
} }
/> />
<Route <Route
path='/task' path='/console/task'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
...@@ -267,7 +270,7 @@ function App() { ...@@ -267,7 +270,7 @@ function App() {
} }
/> />
<Route <Route
path='/chat/:id?' path='/console/chat/:id?'
element={ element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}> <Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Chat /> <Chat />
......
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;
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;
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;
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 };
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess, updateAPI } from '../helpers'; import { useTranslation } from 'react-i18next';
import { UserContext } from '../context/User'; import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { setUserData } from '../helpers/data.js'; import { UserContext } from '../../context/User';
import Loading from '../common/Loading';
const OAuth2Callback = (props) => { const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...'); const [prompt, setPrompt] = useState(t('处理中...'));
const [processing, setProcessing] = useState(true);
let navigate = useNavigate(); let navigate = useNavigate();
...@@ -21,25 +21,25 @@ const OAuth2Callback = (props) => { ...@@ -21,25 +21,25 @@ const OAuth2Callback = (props) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
if (message === 'bind') { if (message === 'bind') {
showSuccess('绑定成功!'); showSuccess(t('绑定成功!'));
navigate('/setting'); navigate('/console/setting');
} else { } else {
userDispatch({ type: 'login', payload: data }); userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data)); localStorage.setItem('user', JSON.stringify(data));
setUserData(data); setUserData(data);
updateAPI(); updateAPI();
showSuccess('登录成功!'); showSuccess(t('登录成功!'));
navigate('/token'); navigate('/console/token');
} }
} else { } else {
showError(message); showError(message);
if (count === 0) { if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`); setPrompt(t('操作失败,重定向至登录界面中...'));
navigate('/setting'); // in case this is failed to bind GitHub navigate('/console/setting'); // in case this is failed to bind GitHub
return; return;
} }
count++; count++;
setPrompt(`出现错误,第 ${count} 次重试中...`); setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
await new Promise((resolve) => setTimeout(resolve, count * 2000)); await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count); await sendCode(code, state, count);
} }
...@@ -51,13 +51,7 @@ const OAuth2Callback = (props) => { ...@@ -51,13 +51,7 @@ const OAuth2Callback = (props) => {
sendCode(code, state, 0).then(); sendCode(code, state, 0).then();
}, []); }, []);
return ( return <Loading prompt={prompt} />;
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
}; };
export default OAuth2Callback; export default OAuth2Callback;
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;
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; 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 PasswordResetForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
email: '', email: '',
}); });
...@@ -16,6 +22,20 @@ const PasswordResetForm = () => { ...@@ -16,6 +22,20 @@ const PasswordResetForm = () => {
const [disableButton, setDisableButton] = useState(false); const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30); 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(() => { useEffect(() => {
let countdownInterval = null; let countdownInterval = null;
if (disableButton && countdown > 0) { if (disableButton && countdown > 0) {
...@@ -29,25 +49,27 @@ const PasswordResetForm = () => { ...@@ -29,25 +49,27 @@ const PasswordResetForm = () => {
return () => clearInterval(countdownInterval); return () => clearInterval(countdownInterval);
}, [disableButton, countdown]); }, [disableButton, countdown]);
function handleChange(e) { function handleChange(value) {
const { name, value } = e.target; setInputs((inputs) => ({ ...inputs, email: value }));
setInputs((inputs) => ({ ...inputs, [name]: value }));
} }
async function handleSubmit(e) { async function handleSubmit(e) {
setDisableButton(true); if (!email) {
if (!email) return; showError(t('请输入邮箱地址'));
return;
}
if (turnstileEnabled && turnstileToken === '') { if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
return; return;
} }
setDisableButton(true);
setLoading(true); setLoading(true);
const res = await API.get( const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`, `/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
); );
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('重置邮件发送成功,请检查邮箱!'); showSuccess(t('重置邮件发送成功,请检查邮箱!'));
setInputs({ ...inputs, email: '' }); setInputs({ ...inputs, email: '' });
} else { } else {
showError(message); showError(message);
...@@ -56,46 +78,69 @@ const PasswordResetForm = () => { ...@@ -56,46 +78,69 @@ const PasswordResetForm = () => {
} }
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Grid.Column style={{ maxWidth: 450 }}> <div className="w-full max-w-sm">
<Header as='h2' color='' textAlign='center'> <div className="flex flex-col items-center">
<Image src='/logo.png' /> 密码重置 <div className="w-full max-w-md">
</Header> <div className="flex items-center justify-center mb-6 gap-2">
<Form size='large'> <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Segment> <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 <Form.Input
fluid field="email"
icon='mail' label={t('邮箱')}
iconPosition='left' placeholder={t('请输入您的邮箱地址')}
placeholder='邮箱地址' name="email"
name='email' size="large"
className="!rounded-md"
value={email} value={email}
onChange={handleChange} onChange={handleChange}
prefix={<IconMail />}
/> />
{turnstileEnabled ? (
<Turnstile <div className="space-y-2 pt-2">
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button <Button
color='green' theme="solid"
fluid className="w-full !rounded-full"
size='large' type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={disableButton} disabled={disableButton}
> >
{disableButton ? `重试 (${countdown})` : '提交'} {disableButton ? `${t('重试')} (${countdown})` : t('提交')}
</Button> </Button>
</Segment> </div>
</Form> </Form>
</Grid.Column>
</Grid> <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>
); );
}; };
......
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;
...@@ -11,8 +11,8 @@ const OIDCIcon = (props) => { ...@@ -11,8 +11,8 @@ const OIDCIcon = (props) => {
version='1.1' version='1.1'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
p-id='10969' p-id='10969'
width='1em' width='20'
height='1em' height='20'
> >
<path <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' 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'
......
...@@ -11,8 +11,8 @@ const WeChatIcon = () => { ...@@ -11,8 +11,8 @@ const WeChatIcon = () => {
version='1.1' version='1.1'
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
p-id='5091' p-id='5091'
width='16' width='20'
height='16' height='20'
> >
<path <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' 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'
......
/* 基础markdown样式 */
.markdown-body {
font-family: inherit;
line-height: 1.6;
color: var(--semi-color-text-0);
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
/* 用户消息样式 - 白色字体适配蓝色背景 */
.user-message {
color: white !important;
}
.user-message .markdown-body {
color: white !important;
}
.user-message h1,
.user-message h2,
.user-message h3,
.user-message h4,
.user-message h5,
.user-message h6 {
color: white !important;
}
.user-message p {
color: white !important;
}
.user-message span {
color: white !important;
}
.user-message div {
color: white !important;
}
.user-message li {
color: white !important;
}
.user-message td,
.user-message th {
color: white !important;
}
.user-message blockquote {
color: white !important;
border-left-color: rgba(255, 255, 255, 0.5) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
.user-message code:not(pre code) {
color: #000 !important;
background-color: rgba(255, 255, 255, 0.9) !important;
}
.user-message a {
color: #87CEEB !important;
/* 浅蓝色链接 */
}
.user-message a:hover {
color: #B0E0E6 !important;
/* hover时更浅的蓝色 */
}
/* 表格在用户消息中的样式 */
.user-message table {
border-color: rgba(255, 255, 255, 0.3) !important;
}
.user-message th {
background-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.user-message td {
border-color: rgba(255, 255, 255, 0.3) !important;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 代码高亮主题 - 适配Semi Design */
.hljs {
display: block;
overflow-x: auto;
padding: 0;
background: transparent;
color: var(--semi-color-text-0);
}
.hljs-comment,
.hljs-quote {
color: var(--semi-color-text-2);
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: var(--semi-color-primary);
font-weight: bold;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr {
color: var(--semi-color-warning);
}
.hljs-string,
.hljs-doctag {
color: var(--semi-color-success);
}
.hljs-title,
.hljs-section,
.hljs-selector-id {
color: var(--semi-color-primary);
font-weight: bold;
}
.hljs-subst {
font-weight: normal;
}
.hljs-type,
.hljs-class .hljs-title {
color: var(--semi-color-info);
font-weight: bold;
}
.hljs-tag,
.hljs-name,
.hljs-attribute {
color: var(--semi-color-primary);
font-weight: normal;
}
.hljs-regexp,
.hljs-link {
color: var(--semi-color-tertiary);
}
.hljs-symbol,
.hljs-bullet {
color: var(--semi-color-warning);
}
.hljs-built_in,
.hljs-builtin-name {
color: var(--semi-color-info);
}
.hljs-meta {
color: var(--semi-color-text-2);
}
.hljs-deletion {
background: var(--semi-color-danger-light-default);
}
.hljs-addition {
background: var(--semi-color-success-light-default);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
/* Mermaid容器样式 */
.mermaid-container {
transition: all 0.2s ease;
}
.mermaid-container:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
/* 代码块样式增强 */
pre {
position: relative;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
transition: all 0.2s ease;
}
pre:hover {
border-color: var(--semi-color-primary) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
pre:hover .copy-code-button {
opacity: 1 !important;
}
.copy-code-button {
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
pointer-events: auto;
}
.copy-code-button:hover {
opacity: 1 !important;
}
.copy-code-button button {
pointer-events: auto !important;
cursor: pointer !important;
}
/* 确保按钮可点击 */
.copy-code-button .semi-button {
pointer-events: auto !important;
cursor: pointer !important;
transition: all 0.2s ease;
}
.copy-code-button .semi-button:hover {
background-color: var(--semi-color-fill-1) !important;
border-color: var(--semi-color-primary) !important;
transform: scale(1.05);
}
/* 表格响应式 */
@media (max-width: 768px) {
.markdown-body table {
font-size: 12px;
}
.markdown-body th,
.markdown-body td {
padding: 6px 8px;
}
}
/* 数学公式样式 */
.katex {
font-size: 1em;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
/* 链接hover效果 */
.markdown-body a {
transition: all 0.2s ease;
}
/* 引用块样式增强 */
.markdown-body blockquote {
position: relative;
}
.markdown-body blockquote::before {
content: '"';
position: absolute;
left: -8px;
top: -8px;
font-size: 24px;
color: var(--semi-color-primary);
opacity: 0.3;
}
/* 列表样式增强 */
.markdown-body ul li::marker {
color: var(--semi-color-primary);
}
.markdown-body ol li::marker {
color: var(--semi-color-primary);
font-weight: bold;
}
/* 分隔线样式 */
.markdown-body hr {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
margin: 24px 0;
}
/* 图片样式 */
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 12px 0;
}
/* 内联代码样式 */
.markdown-body code:not(pre code) {
background-color: var(--semi-color-fill-1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
color: var(--semi-color-primary);
border: 1px solid var(--semi-color-border);
}
/* 标题锚点样式 */
.markdown-body h1:hover,
.markdown-body h2:hover,
.markdown-body h3:hover,
.markdown-body h4:hover,
.markdown-body h5:hover,
.markdown-body h6:hover {
position: relative;
}
/* 任务列表样式 */
.markdown-body input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.1);
}
.markdown-body li.task-list-item {
list-style: none;
margin-left: -20px;
}
/* 键盘按键样式 */
.markdown-body kbd {
background-color: var(--semi-color-fill-0);
border: 1px solid var(--semi-color-border);
border-radius: 3px;
box-shadow: 0 1px 0 var(--semi-color-border);
color: var(--semi-color-text-0);
display: inline-block;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* 详情折叠样式 */
.markdown-body details {
border: 1px solid var(--semi-color-border);
border-radius: 6px;
padding: 12px;
margin: 12px 0;
}
.markdown-body summary {
cursor: pointer;
font-weight: bold;
color: var(--semi-color-primary);
margin-bottom: 8px;
}
.markdown-body summary:hover {
color: var(--semi-color-primary-hover);
}
/* 脚注样式 */
.markdown-body .footnote-ref {
color: var(--semi-color-primary);
text-decoration: none;
font-weight: bold;
}
.markdown-body .footnote-ref:hover {
text-decoration: underline;
}
/* 警告块样式 */
.markdown-body .warning {
background-color: var(--semi-color-warning-light-default);
border-left: 4px solid var(--semi-color-warning);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .info {
background-color: var(--semi-color-info-light-default);
border-left: 4px solid var(--semi-color-info);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .success {
background-color: var(--semi-color-success-light-default);
border-left: 4px solid var(--semi-color-success);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .danger {
background-color: var(--semi-color-danger-light-default);
border-left: 4px solid var(--semi-color-danger);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(6px) scale(0.98);
filter: blur(3px);
}
60% {
opacity: 0.85;
filter: blur(0.5px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity, transform;
}
\ No newline at end of file
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;
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;
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;
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;
\ No newline at end of file
import HeaderBar from './HeaderBar.js'; import HeaderBar from './HeaderBar.js';
import { Layout } from '@douyinfe/semi-ui'; import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar.js'; import SiderBar from './SiderBar.js';
import App from '../App.js'; import App from '../../App.js';
import FooterBar from './Footer.js'; import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react'; 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 { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError } from '../helpers/index.js'; import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { setStatusData } from '../helpers/data.js'; import { UserContext } from '../../context/User/index.js';
import { UserContext } from '../context/User/index.js'; import { StatusContext } from '../../context/Status/index.js';
import { StatusContext } from '../context/Status/index.js'; import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout; const { Sider, Content, Header, Footer } = Layout;
const PageLayout = () => { const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext); const [statusState, statusDispatch] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext); const { state: styleState } = useStyle();
const { i18n } = useTranslation(); 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 = () => { const loadUser = () => {
let user = localStorage.getItem('user'); let user = localStorage.getItem('user');
...@@ -61,15 +68,8 @@ const PageLayout = () => { ...@@ -61,15 +68,8 @@ const PageLayout = () => {
if (savedLang) { if (savedLang) {
i18n.changeLanguage(savedLang); i18n.changeLanguage(savedLang);
} }
// 默认显示侧边栏
styleDispatch({ type: 'SET_SIDER', payload: true });
}, [i18n]); }, [i18n]);
// 获取侧边栏折叠状态
const isSidebarCollapsed =
localStorage.getItem('default_collapse_sidebar') === 'true';
return ( return (
<Layout <Layout
style={{ style={{
...@@ -84,19 +84,18 @@ const PageLayout = () => { ...@@ -84,19 +84,18 @@ const PageLayout = () => {
padding: 0, padding: 0,
height: 'auto', height: 'auto',
lineHeight: 'normal', lineHeight: 'normal',
position: styleState.isMobile ? 'sticky' : 'fixed', position: 'fixed',
width: '100%', width: '100%',
top: 0, top: 0,
zIndex: 100, zIndex: 100,
boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)',
}} }}
> >
<HeaderBar /> <HeaderBar />
</Header> </Header>
<Layout <Layout
style={{ style={{
marginTop: styleState.isMobile ? '0' : '56px', marginTop: '64px',
height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)', height: 'calc(100vh - 64px)',
overflow: styleState.isMobile ? 'visible' : 'auto', overflow: styleState.isMobile ? 'visible' : 'auto',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
...@@ -107,13 +106,11 @@ const PageLayout = () => { ...@@ -107,13 +106,11 @@ const PageLayout = () => {
style={{ style={{
position: 'fixed', position: 'fixed',
left: 0, left: 0,
top: '56px', top: '64px',
zIndex: 99, zIndex: 99,
background: 'var(--semi-color-bg-1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
border: 'none', border: 'none',
paddingRight: '0', paddingRight: '0',
height: 'calc(100vh - 56px)', height: 'calc(100vh - 64px)',
}} }}
> >
<SiderBar /> <SiderBar />
...@@ -126,7 +123,7 @@ const PageLayout = () => { ...@@ -126,7 +123,7 @@ const PageLayout = () => {
: styleState.showSider : styleState.showSider
? styleState.siderCollapsed ? styleState.siderCollapsed
? '60px' ? '60px'
: '200px' : '180px'
: '0', : '0',
transition: 'margin-left 0.3s ease', transition: 'margin-left 0.3s ease',
flex: '1 1 auto', flex: '1 1 auto',
...@@ -137,15 +134,15 @@ const PageLayout = () => { ...@@ -137,15 +134,15 @@ const PageLayout = () => {
<Content <Content
style={{ style={{
flex: '1 0 auto', flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'auto', overflowY: styleState.isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
padding: styleState.shouldInnerPadding ? '24px' : '0', padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
position: 'relative', position: 'relative',
marginTop: styleState.isMobile ? '2px' : '0',
}} }}
> >
<App /> <App />
</Content> </Content>
{!shouldHideFooter && (
<Layout.Footer <Layout.Footer
style={{ style={{
flex: '0 0 auto', flex: '0 0 auto',
...@@ -154,6 +151,7 @@ const PageLayout = () => { ...@@ -154,6 +151,7 @@ const PageLayout = () => {
> >
<FooterBar /> <FooterBar />
</Layout.Footer> </Layout.Footer>
)}
</Layout> </Layout>
</Layout> </Layout>
<ToastContainer /> <ToastContainer />
......
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { StatusContext } from '../context/Status'; import { StatusContext } from '../../context/Status';
const SetupCheck = ({ children }) => { const SetupCheck = ({ children }) => {
const [statusState] = useContext(StatusContext); const [statusState] = useContext(StatusContext);
......
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;
\ No newline at end of file
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;
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment