Commit e2df8011 by Calcium-Ion Committed by GitHub

Merge pull request #1172 from QuantumNous/alpha

feat: 0.8 version
parents a813b241 91fd8b37
name: Publish Docker image (arm64) 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,13 +23,7 @@ jobs: ...@@ -22,13 +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: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
...@@ -43,6 +38,9 @@ jobs: ...@@ -43,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
...@@ -50,6 +48,9 @@ jobs: ...@@ -50,6 +48,9 @@ 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
...@@ -58,4 +59,4 @@ jobs: ...@@ -58,4 +59,4 @@ jobs:
platforms: linux/amd64,linux/arm64 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 }}
\ No newline at end of file
name: Publish Docker image (amd64)
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Save version info
run: |
git describe --tags > VERSION
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
name: Linux Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend (amd64)
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
name: macOS Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: Windows Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
...@@ -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)
......
...@@ -27,14 +27,9 @@ type FunctionCall struct { ...@@ -27,14 +27,9 @@ type FunctionCall struct {
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 {
...@@ -117,10 +112,16 @@ type GeminiChatResponse struct { ...@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
} }
type GeminiUsageMetadata struct { type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"` PromptTokenCount int `json:"promptTokenCount"`
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
} }
...@@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -57,25 +57,63 @@ 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{
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ "gemini-2.5-pro-preview-05-06",
IncludeThoughts: true, "gemini-2.5-pro-preview-03-25",
} }
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) isUnsupported := false
if budgetTokens == 0 || budgetTokens > 24576 { for _, unsupportedModel := range unsupportedModels {
budgetTokens = 24576 if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
} isUnsupported = true
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ break
ThinkingBudget: common.GetPointer(int(budgetTokens)), }
IncludeThoughts: true, }
}
} if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if isNew25Pro {
// 新的2.5pro模型:ThinkingBudget范围为128-32768
if budgetTokens == 0 || budgetTokens < 128 {
budgetTokens = 128
} else if budgetTokens > 32768 {
budgetTokens = 32768
}
} else {
// 其他模型:ThinkingBudget范围为0-24576
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true,
}
}
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") { } else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ // 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128)
ThinkingBudget: common.GetPointer(0), isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if !isNew25Pro {
// 只有非新2.5pro模型才支持-nothinking
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
} }
} }
} }
...@@ -173,17 +211,12 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -173,17 +211,12 @@ 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()) contentMap := common.StrToMap(message.StringContent())
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 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon ...@@ -280,13 +313,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,
}, },
}) })
...@@ -738,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom ...@@ -738,6 +771,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 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re ...@@ -812,6 +852,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 {
......
...@@ -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,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, ...@@ -412,23 +414,43 @@ 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
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio) if !dCacheTokens.IsZero() {
if imageTokens > 0 { baseTokens = baseTokens.Sub(dCacheTokens)
nonImageTokens := dPromptTokens.Sub(dImageTokens) cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
imageTokensWithRatio := dImageTokens.Mul(dImageRatio) }
promptQuota = nonImageTokens.Add(imageTokensWithRatio)
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
} }
// 减去 Gemini audio tokens
if !dAudioTokens.IsZero() {
audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
if audioInputPrice > 0 {
// 重新计算 base tokens
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio) completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio) quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
...@@ -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)
} }
...@@ -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
}
...@@ -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
}
...@@ -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": "https://github.com/mpetazzoni/sse.js",
"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/semi-webpack-plugin": "^2.78.0",
"@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: {},
},
}
No preview for this file type

7.57 KB | W: | H:

9.37 KB | W: | H:

web/public/logo.png
web/public/logo.png
web/public/logo.png
web/public/logo.png
  • 2-up
  • Swipe
  • Onion skin
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}>
<LoginForm /> <AuthRedirect>
<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}>
<RegisterForm /> <AuthRedirect>
<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 { 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 { Spin, Typography, Space } from '@douyinfe/semi-ui';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess, updateAPI } from '../helpers'; import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { UserContext } from '../context/User'; import { UserContext } from '../../context/User';
import { setUserData } from '../helpers/data.js';
const OAuth2Callback = (props) => { const OAuth2Callback = (props) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
...@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => { ...@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => {
}, []); }, []);
return ( return (
<Segment style={{ minHeight: '300px' }}> <div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
<Dimmer active inverted> <Space vertical align="center">
<Loader size='large'>{prompt}</Loader> <Spin size="large" spinning={processing}>
</Dimmer> <div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
</Segment> <Typography.Text type="secondary">{prompt}</Typography.Text>
</div>
</Spin>
</Space>
</div>
); );
}; };
......
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, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
import { API, copy, showError, showNotice } from '../helpers'; import { useSearchParams, Link } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom'; import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail, IconLock } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
const PasswordResetConfirm = () => { const PasswordResetConfirm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
email: '', email: '',
token: '', token: '',
...@@ -11,13 +17,15 @@ const PasswordResetConfirm = () => { ...@@ -11,13 +17,15 @@ const PasswordResetConfirm = () => {
const { email, token } = inputs; const { email, token } = inputs;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false); const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const logo = getLogo();
const systemName = getSystemName();
useEffect(() => { useEffect(() => {
let token = searchParams.get('token'); let token = searchParams.get('token');
let email = searchParams.get('email'); let email = searchParams.get('email');
...@@ -41,8 +49,8 @@ const PasswordResetConfirm = () => { ...@@ -41,8 +49,8 @@ const PasswordResetConfirm = () => {
}, [disableButton, countdown]); }, [disableButton, countdown]);
async function handleSubmit(e) { async function handleSubmit(e) {
if (!email || !token) return;
setDisableButton(true); setDisableButton(true);
if (!email) return;
setLoading(true); setLoading(true);
const res = await API.post(`/api/user/reset`, { const res = await API.post(`/api/user/reset`, {
email, email,
...@@ -53,7 +61,7 @@ const PasswordResetConfirm = () => { ...@@ -53,7 +61,7 @@ const PasswordResetConfirm = () => {
let password = res.data.data; let password = res.data.data;
setNewPassword(password); setNewPassword(password);
await copy(password); await copy(password);
showNotice(`新密码已复制到剪贴板:${password}`); showNotice(`${t('密码已重置并已复制到剪贴板')}: ${password}`);
} else { } else {
showError(message); showError(message);
} }
...@@ -61,52 +69,86 @@ const PasswordResetConfirm = () => { ...@@ -61,52 +69,86 @@ const PasswordResetConfirm = () => {
} }
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<Grid.Column style={{ maxWidth: 450 }}> {/* 背景图片容器 - 放大并保持居中 */}
<Header as='h2' color='' textAlign='center'> <div
<Image src='/logo.png' /> 密码重置确认 className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
</Header> style={{
<Form size='large'> backgroundImage: `url(${Background})`
<Segment> }}
<Form.Input ></div>
fluid
icon='mail' {/* 半透明遮罩层 */}
iconPosition='left' <div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
placeholder='邮箱地址'
name='email' <div className="w-full max-w-sm relative z-10">
value={email} <div className="flex flex-col items-center">
readOnly <div className="w-full max-w-md">
/> <div className="flex items-center justify-center mb-6 gap-2">
{newPassword && ( <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Form.Input <Title heading={3} className='!text-white'>{systemName}</Title>
fluid </div>
icon='lock'
iconPosition='left' <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
placeholder='新密码' <div className="flex justify-center pt-6 pb-2">
name='newPassword' <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
value={newPassword} </div>
readOnly <div className="px-2 py-8">
onClick={(e) => { <Form className="space-y-3">
e.target.select(); <Form.Input
navigator.clipboard.writeText(newPassword); field="email"
showNotice(`密码已复制到剪贴板:${newPassword}`); label={t('邮箱')}
}} name="email"
/> size="large"
)} className="!rounded-md"
<Button value={email}
color='green' readOnly
fluid prefix={<IconMail />}
size='large' />
onClick={handleSubmit}
loading={loading} {newPassword && (
disabled={disableButton} <Form.Input
> field="newPassword"
{disableButton ? `密码重置完成` : '提交'} label={t('新密码')}
</Button> name="newPassword"
</Segment> size="large"
</Form> className="!rounded-md"
</Grid.Column> value={newPassword}
</Grid> readOnly
prefix={<IconLock />}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`${t('密码已复制到剪贴板')}: ${newPassword}`);
}}
/>
)}
<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}
>
{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>
); );
}; };
......
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';
import Background from '/example.png';
const { Text, Title } = Typography;
const PasswordResetForm = () => { const PasswordResetForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
email: '', email: '',
}); });
...@@ -16,6 +23,20 @@ const PasswordResetForm = () => { ...@@ -16,6 +23,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 +50,24 @@ const PasswordResetForm = () => { ...@@ -29,25 +50,24 @@ 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) return; if (!email) 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 +76,80 @@ const PasswordResetForm = () => { ...@@ -56,46 +76,80 @@ const PasswordResetForm = () => {
} }
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<Grid.Column style={{ maxWidth: 450 }}> {/* 背景图片容器 - 放大并保持居中 */}
<Header as='h2' color='' textAlign='center'> <div
<Image src='/logo.png' /> 密码重置 className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
</Header> style={{
<Form size='large'> backgroundImage: `url(${Background})`
<Segment> }}
<Form.Input ></div>
fluid
icon='mail' {/* 半透明遮罩层 */}
iconPosition='left' <div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
placeholder='邮箱地址'
name='email' <div className="w-full max-w-sm relative z-10">
value={email} <div className="flex flex-col items-center">
onChange={handleChange} <div className="w-full max-w-md">
/> <div className="flex items-center justify-center mb-6 gap-2">
{turnstileEnabled ? ( <img src={logo} alt="Logo" className="h-10 rounded-full" />
<Turnstile <Title heading={3} className='!text-white'>{systemName}</Title>
sitekey={turnstileSiteKey} </div>
onVerify={(token) => {
setTurnstileToken(token); <Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
}} <div className="flex justify-center pt-6 pb-2">
/> <Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
) : ( </div>
<></> <div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="email"
label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')}
name="email"
size="large"
className="!rounded-md"
value={email}
onChange={handleChange}
prefix={<IconMail />}
/>
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)} )}
<Button </div>
color='green' </div>
fluid </div>
size='large' </div>
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `重试 (${countdown})` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
); );
}; };
......
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 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{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 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{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 hover:!text-semi-color-primary transition-colors">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">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 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">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>
{isDemoSiteMode && (
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<span className="!text-semi-color-primary">Douyin FE</span>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</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',
...@@ -139,21 +136,23 @@ const PageLayout = () => { ...@@ -139,21 +136,23 @@ const PageLayout = () => {
flex: '1 0 auto', flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'auto', overflowY: styleState.isMobile ? 'visible' : 'auto',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
padding: styleState.shouldInnerPadding ? '24px' : '0', padding: shouldInnerPadding ? '24px' : '0',
position: 'relative', position: 'relative',
marginTop: styleState.isMobile ? '2px' : '0', marginTop: styleState.isMobile ? '2px' : '0',
}} }}
> >
<App /> <App />
</Content> </Content>
<Layout.Footer {!shouldHideFooter && (
style={{ <Layout.Footer
flex: '0 0 auto', style={{
width: '100%', flex: '0 0 auto',
}} width: '100%',
> }}
<FooterBar /> >
</Layout.Footer> <FooterBar />
</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
import React, { useRef } from 'react';
import {
Button,
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
const ConfigManager = ({
currentConfig,
onConfigImport,
onConfigReset,
styleState,
messages,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
// 在导出前先保存当前配置,确保导出的是最新内容
const configWithTimestamp = {
...currentConfig,
timestamp: new Date().toISOString(),
};
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
exportConfig(currentConfig, messages);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
});
} catch (error) {
Toast.error({
content: t('导出配置失败: ') + error.message,
duration: 3,
});
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const importedConfig = await importConfig(file);
Modal.confirm({
title: t('确认导入配置'),
content: t('导入的配置将覆盖当前设置,是否继续?'),
okText: t('确定导入'),
cancelText: t('取消'),
onOk: () => {
onConfigImport(importedConfig);
Toast.success({
content: t('配置导入成功'),
duration: 3,
});
},
});
} catch (error) {
Toast.error({
content: t('导入配置失败: ') + error.message,
duration: 3,
});
} finally {
// 重置文件输入,允许重复选择同一文件
event.target.value = '';
}
};
const handleReset = () => {
Modal.confirm({
title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
okText: t('确定重置'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
// 询问是否同时重置消息
Modal.confirm({
title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
okText: t('同时重置消息'),
cancelText: t('仅重置配置'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset({ resetMessages: true });
Toast.success({
content: t('配置和消息已全部重置'),
duration: 3,
});
},
onCancel: () => {
clearConfig();
onConfigReset({ resetMessages: false });
Toast.success({
content: t('配置已重置,对话消息已保留'),
duration: 3,
});
},
});
},
});
};
const getConfigStatus = () => {
if (hasStoredConfig()) {
const timestamp = getConfigTimestamp();
if (timestamp) {
const date = new Date(timestamp);
return t('上次保存: ') + date.toLocaleString();
}
return t('已有保存的配置');
}
return t('暂无保存的配置');
};
const dropdownItems = [
{
node: 'item',
name: 'export',
onClick: handleExport,
children: (
<div className="flex items-center gap-2">
<Download size={14} />
{t('导出配置')}
</div>
),
},
{
node: 'item',
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<Upload size={14} />
{t('导入配置')}
</div>
),
},
{
node: 'divider',
},
{
node: 'item',
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<RotateCcw size={14} />
{t('重置配置')}
</div>
),
},
];
if (styleState.isMobile) {
// 移动端显示简化的下拉菜单
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</>
);
}
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
{/* 配置状态信息和重置按钮 */}
<div className="flex items-center justify-between">
<Typography.Text className="text-xs text-gray-500">
{getConfigStatus()}
</Typography.Text>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={handleReset}
className="!rounded-full !text-xs !px-2"
/>
</div>
{/* 导出和导入按钮 */}
<div className="flex gap-2">
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导入')}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
export default ConfigManager;
\ No newline at end of file
import React from 'react';
const CustomInputRender = (props) => {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
// 清空按钮
const styledClearNode = clearContextNode
? React.cloneElement(clearContextNode, {
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
style: {
...clearContextNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
})
: null;
// 发送按钮
const styledSendNode = React.cloneElement(sendNode, {
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
style: {
...sendNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
});
return (
<div className="p-2 sm:p-4">
<div
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}
<div className="flex-1">
{inputNode}
</div>
{/* 发送按钮 - 右边 */}
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;
\ No newline at end of file
import React, { useState, useEffect } from 'react';
import {
TextArea,
Typography,
Button,
Switch,
Banner,
} from '@douyinfe/semi-ui';
import {
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({
customRequestMode,
customRequestBody,
onCustomRequestModeChange,
onCustomRequestBodyChange,
defaultPayload,
}) => {
const { t } = useTranslation();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [localValue, setLocalValue] = useState(customRequestBody || '');
// 当切换到自定义模式时,用默认payload初始化
useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
// 同步外部传入的customRequestBody到本地状态
useEffect(() => {
if (customRequestBody !== localValue) {
setLocalValue(customRequestBody || '');
validateJson(customRequestBody || '');
}
}, [customRequestBody]);
// 验证JSON格式
const validateJson = (value) => {
if (!value.trim()) {
setIsValid(true);
setErrorMessage('');
return true;
}
try {
JSON.parse(value);
setIsValid(true);
setErrorMessage('');
return true;
} catch (error) {
setIsValid(false);
setErrorMessage(`JSON格式错误: ${error.message}`);
return false;
}
};
const handleValueChange = (value) => {
setLocalValue(value);
validateJson(value);
// 始终保存用户输入,让预览逻辑处理JSON解析错误
onCustomRequestBodyChange(value);
};
const handleModeToggle = (enabled) => {
onCustomRequestModeChange(enabled);
if (enabled && defaultPayload) {
const defaultJson = JSON.stringify(defaultPayload, null, 2);
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
};
const formatJson = () => {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
onCustomRequestBodyChange(formatted);
setIsValid(true);
setErrorMessage('');
} catch (error) {
// 如果格式化失败,保持原样
}
};
return (
<div className="space-y-4">
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
自定义请求体模式
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
closable={false}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<Check size={14} />
<Typography.Text className="text-xs">
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<X size={14} />
<Typography.Text className="text-xs">
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
>
格式化
</Button>
</div>
</div>
<TextArea
value={localValue}
onChange={handleValueChange}
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
autosize={{ minRows: 8, maxRows: 20 }}
className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
style={{
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: '1.5',
}}
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;
\ No newline at end of file
import React, { useState, useEffect } from 'react';
import {
Card,
Typography,
Tabs,
TabPane,
Button,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Code,
Zap,
Clock,
X,
Eye,
Send,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer';
const DebugPanel = ({
debugData,
activeDebugTab,
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
customRequestMode,
}) => {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState(activeDebugTab);
useEffect(() => {
setActiveKey(activeDebugTab);
}, [activeDebugTab]);
const handleTabChange = (key) => {
setActiveKey(key);
onActiveDebugTabChange(key);
};
const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => handleTabChange(item.itemKey)}
>
{item.tab}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
{pos === 'start' ? (
<div style={style} onClick={handleArrowClick}>
</div>
) : (
<div style={style} onClick={handleArrowClick}>
</div>
)}
</Dropdown>
);
};
return (
<Card
className="h-full flex flex-col"
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
{styleState.isMobile && onCloseDebugPanel && (
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
renderArrow={renderArrow}
type="card"
collapsible
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeKey}
onChange={handleTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<CodeViewer
content={debugData.previewRequest}
title="preview"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Send size={16} />
{t('实际请求体')}
</div>
} itemKey="request">
<CodeViewer
content={debugData.request}
title="request"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应')}
</div>
} itemKey="response">
<CodeViewer
content={debugData.response}
title="response"
language="json"
/>
</TabPane>
</Tabs>
</div>
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
{(debugData.timestamp || debugData.previewTimestamp) && (
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{activeKey === 'preview' && debugData.previewTimestamp
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
: debugData.timestamp
? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
: ''}
</Typography.Text>
</div>
)}
</div>
</Card>
);
};
export default DebugPanel;
\ No newline at end of file
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({
styleState,
showSettings,
showDebugPanel,
onToggleSettings,
onToggleDebugPanel,
}) => {
if (!styleState.isMobile) return null;
return (
<>
{/* 设置按钮 */}
{!showSettings && (
<Button
icon={<Settings size={18} />}
style={{
position: 'fixed',
right: 16,
bottom: 90,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
}}
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
/>
)}
{/* 调试按钮 */}
{!showSettings && (
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
style={{
position: 'fixed',
right: 16,
bottom: 140,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: showDebugPanel
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden !rounded-full !p-0"
/>
)}
</>
);
};
export default FloatingButtons;
\ No newline at end of file
import React from 'react';
import {
Input,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
Image,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
};
const handleUpdateImageUrl = (index, value) => {
const newUrls = [...imageUrls];
newUrls[index] = value;
onImageUrlsChange(newUrls);
};
const handleRemoveImageUrl = (index) => {
const newUrls = imageUrls.filter((_, i) => i !== index);
onImageUrlsChange(newUrls);
};
return (
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText="启用"
uncheckedText="停用"
size="small"
className="flex-shrink-0"
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={!imageEnabled || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
disabled={!imageEnabled || disabled}
/>
</div>
))}
</div>
</div>
);
};
export default ImageUrlInput;
\ No newline at end of file
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
UserCheck,
Edit,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({
message,
styleState,
onMessageReset,
onMessageCopy,
onMessageDelete,
onRoleToggle,
onMessageEdit,
isAnyMessageGenerating = false,
isEditing = false
}) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating || isEditing;
const canToggleRole = message.role === 'assistant' || message.role === 'system';
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('复制')}
/>
</Tooltip>
)}
{canEdit && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageEdit(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-yellow-600 hover:!bg-yellow-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('编辑')}
/>
</Tooltip>
)}
{canToggleRole && !isLoading && (
<Tooltip
content={
shouldDisableActions
? t('操作暂时被禁用')
: message.role === 'assistant'
? t('切换为System角色')
: t('切换为Assistant角色')
}
position="top"
>
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
};
export default MessageActions;
\ No newline at end of file
import React from 'react';
import MessageContent from './MessageContent';
import MessageActions from './MessageActions';
import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel';
// 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.role === nextProps.message.role &&
prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
});
// 优化的消息操作组件
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.role === nextProps.message.role &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.onMessageReset === nextProps.onMessageReset
);
});
// 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
});
// 优化的调试面板组件
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
});
\ No newline at end of file
import React from 'react';
import {
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import {
Hash,
Thermometer,
Target,
Repeat,
Ban,
Shuffle,
Check,
X,
} from 'lucide-react';
const ParameterControl = ({
inputs,
parameterEnabled,
onInputChange,
onParameterToggle,
disabled = false,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature || disabled}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('top_p')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样,控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p || disabled}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('frequency_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚,减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty || disabled}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('presence_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚,鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty || disabled}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('max_tokens')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选,用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed || disabled}
/>
</div>
</>
);
};
export default ParameterControl;
\ No newline at end of file
import React from 'react';
import {
Card,
Select,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
ToggleLeft,
X,
Settings,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption } from '../../helpers';
import ParameterControl from './ParameterControl';
import ImageUrlInput from './ImageUrlInput';
import ConfigManager from './ConfigManager';
import CustomRequestEditor from './CustomRequestEditor';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
styleState,
showDebugPanel,
customRequestMode,
customRequestBody,
onInputChange,
onParameterToggle,
onCloseSettings,
onConfigImport,
onConfigReset,
onCustomRequestModeChange,
onCustomRequestBodyChange,
previewPayload,
messages,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
};
return (
<Card
className="h-full flex flex-col"
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{/* 标题区域 - 与调试面板保持一致 */}
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
<Settings size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('模型配置')}
</Typography.Title>
</div>
{styleState.isMobile && onCloseSettings && (
<Button
icon={<X size={16} />}
onClick={onCloseSettings}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
{/* 移动端配置管理 */}
{styleState.isMobile && (
<div className="mb-4 flex-shrink-0">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={{ ...styleState, isMobile: false }}
messages={messages}
/>
</div>
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 自定义请求体编辑器 */}
<CustomRequestEditor
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onCustomRequestModeChange={onCustomRequestModeChange}
onCustomRequestBodyChange={onCustomRequestBodyChange}
defaultPayload={previewPayload}
/>
{/* 分组选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('分组')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => onInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 模型选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('模型')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => onInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 图片URL输入 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
disabled={customRequestMode}
/>
</div>
{/* 参数控制组件 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
disabled={customRequestMode}
/>
</div>
{/* 流式输出开关 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
流式输出
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText="开"
uncheckedText="关"
size="small"
disabled={customRequestMode}
/>
</div>
</div>
</div>
{/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && (
<div className="flex-shrink-0 pt-3">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
</div>
)}
</Card>
);
};
export default SettingsPanel;
\ No newline at end of file
import React, { useEffect, useRef } from 'react';
import { Typography } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ThinkingContent = ({
message,
finalExtractedThinkingContent,
thinkingSource,
styleState,
onToggleReasoningExpansion
}) => {
const { t } = useTranslation();
const scrollRef = useRef(null);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
useEffect(() => {
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
useEffect(() => {
if (!isThinkingStatus) {
lastContentRef.current = '';
}
}, [isThinkingStatus]);
if (!finalExtractedThinkingContent) return null;
let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) {
if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
prevLength = lastContentRef.current.length;
}
}
if (isThinkingStatus) {
lastContentRef.current = finalExtractedThinkingContent;
}
return (
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="flex items-center gap-2 sm:gap-4 relative">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
</div>
<div className="flex flex-col">
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 relative">
{isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
思考中
</Typography.Text>
</div>
)}
{(!isThinkingStatus || message.isThinkingComplete) && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
}
</div>
)}
</div>
</div>
<div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
>
{message.isReasoningExpanded && (
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
<div
ref={scrollRef}
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
style={{
maxHeight: '200px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
}}
>
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<MarkdownRenderer
content={finalExtractedThinkingContent}
className=""
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ThinkingContent;
\ No newline at end of file
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
const MESSAGES_STORAGE_KEY = 'playground_messages';
/**
* 保存配置到 localStorage
* @param {Object} config - 要保存的配置对象
*/
export const saveConfig = (config) => {
try {
const configToSave = {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 保存消息到 localStorage
* @param {Array} messages - 要保存的消息数组
*/
export const saveMessages = (messages) => {
try {
const messagesToSave = {
messages,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
} catch (error) {
console.error('保存消息失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
return mergedConfig;
}
} catch (error) {
console.error('加载配置失败:', error);
}
return DEFAULT_CONFIG;
};
/**
* 从 localStorage 加载消息
* @returns {Array} 消息数组,如果不存在则返回 null
*/
export const loadMessages = () => {
try {
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
if (savedMessages) {
const parsedMessages = JSON.parse(savedMessages);
return parsedMessages.messages || null;
}
} catch (error) {
console.error('加载消息失败:', error);
}
return null;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG);
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 清除保存的消息
*/
export const clearMessages = () => {
try {
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
} catch (error) {
console.error('清除消息失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
}
};
/**
* 获取配置的最后保存时间
* @returns {string|null} 最后保存时间的 ISO 字符串
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
}
} catch (error) {
console.error('获取配置时间戳失败:', error);
}
return null;
};
/**
* 导出配置为 JSON 文件(包含消息)
* @param {Object} config - 要导出的配置
* @param {Array} messages - 要导出的消息
*/
export const exportConfig = (config, messages = null) => {
try {
const configToExport = {
...config,
messages: messages || loadMessages(), // 包含消息数据
exportTime: new Date().toISOString(),
version: '1.0',
};
const dataStr = JSON.stringify(configToExport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('导出配置失败:', error);
}
};
/**
* 从文件导入配置(包含消息)
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
export const importConfig = (file) => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
saveMessages(importedConfig.messages);
}
resolve(importedConfig);
} else {
reject(new Error('配置文件格式无效'));
}
} catch (parseError) {
reject(new Error('解析配置文件失败: ' + parseError.message));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
} catch (error) {
reject(new Error('导入配置失败: ' + error.message));
}
});
};
\ No newline at end of file
export { default as SettingsPanel } from './SettingsPanel';
export { default as ChatArea } from './ChatArea';
export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';
export { default as ConfigManager } from './ConfigManager';
export {
saveConfig,
loadConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';
\ No newline at end of file
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js'; import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js'; import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js'; import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
const ModelSetting = () => { const ModelSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
......
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js'; import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js'; import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js'; import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../pages/Setting/Operation/SettingsLog.js'; import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js'; import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js'; import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js'; import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js'; import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js'; import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js'; import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js'; import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js'; import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
const OperationSetting = () => { const OperationSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
......
...@@ -9,10 +9,10 @@ import { ...@@ -9,10 +9,10 @@ import {
Space, Space,
Card, Card,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { API, showError, showSuccess, timestamp2string } from '../helpers'; import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { marked } from 'marked'; import { marked } from 'marked';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { StatusContext } from '../context/Status/index.js'; import { StatusContext } from '../../context/Status/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const OtherSetting = () => { const OtherSetting = () => {
......
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui'; import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../../helpers/index.js';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js'; import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js'; import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
const RateLimitSetting = () => { const RateLimitSetting = () => {
const { t } = useTranslation(); const { t } = useTranslation();
......
...@@ -13,12 +13,12 @@ import { ...@@ -13,12 +13,12 @@ import {
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
const { Text } = Typography; const { Text } = Typography;
import { import {
API,
removeTrailingSlash, removeTrailingSlash,
showError, showError,
showSuccess, showSuccess,
verifyJSON, verifyJSON
} from '../helpers/utils'; } from '../../helpers';
import { API } from '../helpers/api';
import axios from 'axios'; import axios from 'axios';
const SystemSetting = () => { const SystemSetting = () => {
......
import { API, showError } from '../helpers';
export async function getOAuthState() {
let path = '/api/oauth/state';
let affCode = localStorage.getItem('aff');
if (affCode && affCode.length > 0) {
path += `?aff=${affCode}`;
}
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = 'code';
const scope = 'openid profile email';
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) {
window.open(url);
} else {
window.location.href = url;
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
if (!channelModels[type]) {
return [];
}
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}
...@@ -113,7 +113,7 @@ export const CHANNEL_OPTIONS = [ ...@@ -113,7 +113,7 @@ export const CHANNEL_OPTIONS = [
{ {
value: 45, value: 45,
color: 'blue', color: 'blue',
label: '字节火山方舟、豆包、DeepSeek通用', label: '字节火山方舟、豆包通用',
}, },
{ {
value: 48, value: 48,
......
export * from './toast.constants'; export * from './channel.constants';
export * from './user.constants'; export * from './user.constants';
export * from './toast.constants';
export * from './common.constant'; export * from './common.constant';
export * from './channel.constants'; export * from './playground.constants';
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