mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-03 16:39:46 +00:00
Compare commits
44 Commits
v0.9.2.1
...
v0.9.3-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407da544fe | ||
|
|
98261ec9fa | ||
|
|
74f93d41f3 | ||
|
|
021892b17d | ||
|
|
9f44116260 | ||
|
|
8a56795bd8 | ||
|
|
1154077eea | ||
|
|
42861bc5fb | ||
|
|
7074ea2ed6 | ||
|
|
414be64d33 | ||
|
|
c1137027e6 | ||
|
|
ff77ba1157 | ||
|
|
3da7cebec6 | ||
|
|
7dc5f8c92d | ||
|
|
7763f11da7 | ||
|
|
7437b671ef | ||
|
|
55d19df029 | ||
|
|
731e9f4ca9 | ||
|
|
cc6fcebda1 | ||
|
|
72a12e3747 | ||
|
|
0adfcf9d27 | ||
|
|
51d71a6e1a | ||
|
|
8026e5142b | ||
|
|
9e33c83351 | ||
|
|
d2492d2af9 | ||
|
|
c9abe1d769 | ||
|
|
a8bfa7ad29 | ||
|
|
93e30703d4 | ||
|
|
b39885be1e | ||
|
|
f24feed775 | ||
|
|
6b75bc0016 | ||
|
|
937d931442 | ||
|
|
5c6e6032ef | ||
|
|
69e1542fc9 | ||
|
|
3199e2e8cd | ||
|
|
15b21c075f | ||
|
|
922ecef31e | ||
|
|
573b5c3e3b | ||
|
|
39a868faea | ||
|
|
7c7f9abd04 | ||
|
|
050e0221c7 | ||
|
|
a57a36a739 | ||
|
|
0046282fb8 | ||
|
|
723eefe9d8 |
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Describe the issue you encountered with clear and detailed language
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README, especially the FAQ section
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Issue Description**
|
||||
|
||||
**Steps to Reproduce**
|
||||
|
||||
**Expected Result**
|
||||
|
||||
**Related Screenshots**
|
||||
If none, please delete this section.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Describe the new feature you would like to add with clear and detailed language
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Feature Description**
|
||||
|
||||
**Use Case**
|
||||
|
||||
@@ -13,7 +13,3 @@
|
||||
### PR 描述
|
||||
|
||||
**请在下方详细描述您的 PR,包括目的、实现细节等。**
|
||||
|
||||
### **重要提示**
|
||||
|
||||
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**
|
||||
|
||||
142
.github/workflows/electron-build.yml
vendored
Normal file
142
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*' # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
# os: [macos-latest, windows-latest]
|
||||
os: [windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.18.0'
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
|
||||
# - name: Build Go binary (macos/Linux)
|
||||
# if: runner.os != 'Windows'
|
||||
# run: |
|
||||
# go mod download
|
||||
# go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
|
||||
- name: Build Go binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||
|
||||
- name: Update Electron version
|
||||
run: |
|
||||
cd electron
|
||||
VERSION=$(git describe --tags)
|
||||
VERSION=${VERSION#v} # Remove 'v' prefix if present
|
||||
# Convert to valid semver: take first 3 components and convert rest to prerelease format
|
||||
# e.g., 0.9.3-patch.1 -> 0.9.3-patch.1
|
||||
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then
|
||||
MAJOR=${BASH_REMATCH[1]}
|
||||
MINOR=${BASH_REMATCH[2]}
|
||||
PATCH=${BASH_REMATCH[3]}
|
||||
REST=${BASH_REMATCH[4]}
|
||||
|
||||
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
|
||||
# If there's extra content, append it without adding -dev
|
||||
if [[ -n "$REST" ]]; then
|
||||
VERSION="$VERSION$REST"
|
||||
fi
|
||||
fi
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install Electron dependencies
|
||||
run: |
|
||||
cd electron
|
||||
npm install
|
||||
|
||||
# - name: Build Electron app (macOS)
|
||||
# if: runner.os == 'macOS'
|
||||
# run: |
|
||||
# cd electron
|
||||
# npm run build:mac
|
||||
# env:
|
||||
# CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
cd electron
|
||||
npm run build:win
|
||||
|
||||
# - name: Upload artifacts (macOS)
|
||||
# if: runner.os == 'macOS'
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: macos-build
|
||||
# path: |
|
||||
# electron/dist/*.dmg
|
||||
# electron/dist/*.zip
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-build
|
||||
path: |
|
||||
electron/dist/*.exe
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
windows-build/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
overwrite_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
11
.github/workflows/linux-release.yml
vendored
11
.github/workflows/linux-release.yml
vendored
@@ -38,21 +38,22 @@ jobs:
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
|
||||
- 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 new-api-arm64
|
||||
VERSION=$(git describe --tags)
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
new-api
|
||||
new-api-arm64
|
||||
new-api-*
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
|
||||
7
.github/workflows/macos-release.yml
vendored
7
.github/workflows/macos-release.yml
vendored
@@ -39,12 +39,13 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-X 'one-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: new-api-macos
|
||||
files: new-api-macos-*
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
|
||||
7
.github/workflows/windows-release.yml
vendored
7
.github/workflows/windows-release.yml
vendored
@@ -41,12 +41,13 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: new-api.exe
|
||||
files: new-api-*.exe
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -9,6 +9,10 @@ logs
|
||||
web/dist
|
||||
.env
|
||||
one-api
|
||||
new-api
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
32
README.en.md
32
README.en.md
@@ -89,22 +89,23 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
||||
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. Support for entering chat interface via /chat2link route
|
||||
15. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||
1. OpenAI o-series models
|
||||
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
||||
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
||||
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
||||
2. Claude thinking models
|
||||
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Thinking-to-content functionality
|
||||
17. 🔄 Model rate limiting for users
|
||||
18. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
17. 🔄 Thinking-to-content functionality
|
||||
18. 🔄 Model rate limiting for users
|
||||
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
||||
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
||||
3. Supported channels:
|
||||
@@ -134,14 +135,12 @@ For detailed configuration instructions, please refer to [Installation Guide-Env
|
||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
|
||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
||||
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
||||
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
||||
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
|
||||
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
|
||||
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
||||
@@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
```
|
||||
|
||||
## Channel Retry and Cache
|
||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
|
||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
|
||||
|
||||
### Cache Configuration Method
|
||||
1. `REDIS_CONN_STRING`: Set Redis as cache
|
||||
@@ -198,22 +197,21 @@ Channel retry functionality has been implemented, you can set the number of retr
|
||||
|
||||
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
||||
|
||||
- [Chat API](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Image API](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Related Projects
|
||||
- [One API](https://github.com/songquanpeng/one-api): Original project
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
||||
|
||||
Other projects based on New API:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
|
||||
|
||||
## Help and Support
|
||||
|
||||
|
||||
32
README.fr.md
32
README.fr.md
@@ -89,22 +89,23 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à
|
||||
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link
|
||||
15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||
1. Modèles de la série o d'OpenAI
|
||||
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
||||
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
||||
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
||||
2. Modèles de pensée de Claude
|
||||
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Fonctionnalité de la pensée au contenu
|
||||
17. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
17. 🔄 Fonctionnalité de la pensée au contenu
|
||||
18. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||
3. Canaux pris en charge :
|
||||
@@ -134,14 +135,12 @@ Pour des instructions de configuration détaillées, veuillez vous référer à
|
||||
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
||||
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
||||
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
||||
- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
||||
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
||||
- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
||||
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données
|
||||
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
|
||||
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
||||
@@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
```
|
||||
|
||||
## Nouvelle tentative de canal et cache
|
||||
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**.
|
||||
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
|
||||
|
||||
### Méthode de configuration du cache
|
||||
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
||||
@@ -198,22 +197,21 @@ La fonctionnalité de nouvelle tentative de canal a été implémentée, vous po
|
||||
|
||||
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
||||
|
||||
- [API de discussion](https://docs.newapi.pro/api/openai-chat)
|
||||
- [API d'image](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Projets connexes
|
||||
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||
|
||||
Autres projets basés sur New API :
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
|
||||
|
||||
## Aide et support
|
||||
|
||||
|
||||
18
README.ja.md
18
README.ja.md
@@ -89,22 +89,23 @@ New APIは豊富な機能を提供しています。詳細な機能について
|
||||
10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC)
|
||||
11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. /chat2linkルートを使用してチャット画面に入ることをサポート
|
||||
15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
1. OpenAI oシリーズモデル
|
||||
- `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`)
|
||||
- `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`)
|
||||
- `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`)
|
||||
2. Claude思考モデル
|
||||
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考からコンテンツへの機能
|
||||
17. 🔄 ユーザーに対するモデルレート制限機能
|
||||
18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
17. 🔄 思考からコンテンツへの機能
|
||||
18. 🔄 ユーザーに対するモデルレート制限機能
|
||||
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定
|
||||
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
|
||||
3. サポートされているチャネル:
|
||||
@@ -196,7 +197,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
|
||||
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
|
||||
|
||||
- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [チャットインターフェース(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [レスポンスインターフェース(Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
|
||||
22
README.md
22
README.md
@@ -85,22 +85,23 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
||||
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. 支持使用路由/chat2link进入聊天界面
|
||||
15. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||
1. OpenAI o系列模型
|
||||
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
||||
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
||||
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
||||
2. Claude 思考模型
|
||||
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考转内容功能
|
||||
17. 🔄 针对用户的模型限流功能
|
||||
18. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
17. 🔄 思考转内容功能
|
||||
18. 🔄 针对用户的模型限流功能
|
||||
19. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages (OpenAI格式调用Claude模型)
|
||||
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
3. OpenAI Chat Completions => Gemini Chat (OpenAI格式调用Gemini模型)
|
||||
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
||||
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
||||
3. 支持的渠道:
|
||||
@@ -192,7 +193,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
|
||||
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
||||
|
||||
- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [聊天接口(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
|
||||
@@ -19,6 +19,7 @@ var TopUpLink = ""
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||
var DisplayInCurrencyEnabled = true
|
||||
var DisplayTokenStatEnabled = true
|
||||
var DrawingEnabled = true
|
||||
|
||||
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
||||
var UsingMySQL = false
|
||||
var UsingClickHouse = false
|
||||
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
APITypeXai
|
||||
APITypeCoze
|
||||
APITypeJimeng
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -113,3 +113,64 @@ var ChannelBaseURLs = []string{
|
||||
"https://llm.submodel.ai", //53
|
||||
"https://ark.cn-beijing.volces.com", //54
|
||||
}
|
||||
|
||||
var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeUnknown: "Unknown",
|
||||
ChannelTypeOpenAI: "OpenAI",
|
||||
ChannelTypeMidjourney: "Midjourney",
|
||||
ChannelTypeAzure: "Azure",
|
||||
ChannelTypeOllama: "Ollama",
|
||||
ChannelTypeMidjourneyPlus: "MidjourneyPlus",
|
||||
ChannelTypeOpenAIMax: "OpenAIMax",
|
||||
ChannelTypeOhMyGPT: "OhMyGPT",
|
||||
ChannelTypeCustom: "Custom",
|
||||
ChannelTypeAILS: "AILS",
|
||||
ChannelTypeAIProxy: "AIProxy",
|
||||
ChannelTypePaLM: "PaLM",
|
||||
ChannelTypeAPI2GPT: "API2GPT",
|
||||
ChannelTypeAIGC2D: "AIGC2D",
|
||||
ChannelTypeAnthropic: "Anthropic",
|
||||
ChannelTypeBaidu: "Baidu",
|
||||
ChannelTypeZhipu: "Zhipu",
|
||||
ChannelTypeAli: "Ali",
|
||||
ChannelTypeXunfei: "Xunfei",
|
||||
ChannelType360: "360",
|
||||
ChannelTypeOpenRouter: "OpenRouter",
|
||||
ChannelTypeAIProxyLibrary: "AIProxyLibrary",
|
||||
ChannelTypeFastGPT: "FastGPT",
|
||||
ChannelTypeTencent: "Tencent",
|
||||
ChannelTypeGemini: "Gemini",
|
||||
ChannelTypeMoonshot: "Moonshot",
|
||||
ChannelTypeZhipu_v4: "ZhipuV4",
|
||||
ChannelTypePerplexity: "Perplexity",
|
||||
ChannelTypeLingYiWanWu: "LingYiWanWu",
|
||||
ChannelTypeAws: "AWS",
|
||||
ChannelTypeCohere: "Cohere",
|
||||
ChannelTypeMiniMax: "MiniMax",
|
||||
ChannelTypeSunoAPI: "SunoAPI",
|
||||
ChannelTypeDify: "Dify",
|
||||
ChannelTypeJina: "Jina",
|
||||
ChannelCloudflare: "Cloudflare",
|
||||
ChannelTypeSiliconFlow: "SiliconFlow",
|
||||
ChannelTypeVertexAi: "VertexAI",
|
||||
ChannelTypeMistral: "Mistral",
|
||||
ChannelTypeDeepSeek: "DeepSeek",
|
||||
ChannelTypeMokaAI: "MokaAI",
|
||||
ChannelTypeVolcEngine: "VolcEngine",
|
||||
ChannelTypeBaiduV2: "BaiduV2",
|
||||
ChannelTypeXinference: "Xinference",
|
||||
ChannelTypeXai: "xAI",
|
||||
ChannelTypeCoze: "Coze",
|
||||
ChannelTypeKling: "Kling",
|
||||
ChannelTypeJimeng: "Jimeng",
|
||||
ChannelTypeVidu: "Vidu",
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
if name, ok := ChannelTypeNames[channelType]; ok {
|
||||
return name
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func GetSubscription(c *gin.Context) {
|
||||
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
|
||||
}
|
||||
quota := remainQuota + usedQuota
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
|
||||
// 我们将其解释为以“站点展示类型”为准:
|
||||
// - USD: 直接除以 QuotaPerUnit
|
||||
// - CNY: 先转 USD 再乘汇率
|
||||
// - TOKENS: 直接使用 tokens 数量
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// amount 保持 tokens 数值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
if token != nil && token.UnlimitedQuota {
|
||||
amount = 100000000
|
||||
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// tokens 保持原值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
usage := OpenAIUsageResponse{
|
||||
Object: "list",
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,46 +41,19 @@ type testResult struct {
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
|
||||
tik := time.Now()
|
||||
if channel.Type == constant.ChannelTypeMidjourney {
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
constant.ChannelTypeMidjourneyPlus,
|
||||
constant.ChannelTypeSunoAPI,
|
||||
constant.ChannelTypeKling,
|
||||
constant.ChannelTypeJimeng,
|
||||
constant.ChannelTypeDoubaoVideo,
|
||||
constant.ChannelTypeVidu,
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeMidjourneyPlus {
|
||||
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
|
||||
channelTypeName := constant.GetChannelTypeName(channel.Type)
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney plus channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeSunoAPI {
|
||||
return testResult{
|
||||
localErr: errors.New("suno channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeKling {
|
||||
return testResult{
|
||||
localErr: errors.New("kling channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeJimeng {
|
||||
return testResult{
|
||||
localErr: errors.New("jimeng channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeDoubaoVideo {
|
||||
return testResult{
|
||||
localErr: errors.New("doubao video channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeVidu {
|
||||
return testResult{
|
||||
localErr: errors.New("vidu channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
localErr: fmt.Errorf("%s channel test is not supported", channelTypeName),
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -66,18 +66,22 @@ func GetStatus(c *gin.Context) {
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
||||
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
||||
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
||||
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
||||
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
"price": operation_setting.Price,
|
||||
|
||||
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
|
||||
|
||||
func getPayMoney(amount int64, group string) float64 {
|
||||
dAmount := decimal.NewFromInt(amount)
|
||||
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
// 充值金额以“展示类型”为准:
|
||||
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
dAmount = dAmount.Div(dQuotaPerUnit)
|
||||
}
|
||||
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
|
||||
|
||||
func getMinTopup() int64 {
|
||||
minTopup := operation_setting.MinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
||||
@@ -176,7 +177,7 @@ func RequestEpay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := req.Amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dAmount := decimal.NewFromInt(int64(amount))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||
|
||||
@@ -258,7 +258,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
|
||||
|
||||
func getStripePayMoney(amount float64, group string) float64 {
|
||||
originalAmount := amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
||||
@@ -279,7 +279,7 @@ func getStripePayMoney(amount float64, group string) float64 {
|
||||
|
||||
func getStripeMinTopup() int64 {
|
||||
minTopup := setting.StripeMinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
minTopup = minTopup * int(common.QuotaPerUnit)
|
||||
}
|
||||
return int64(minTopup)
|
||||
|
||||
72
docs/translation-glossary.md
Normal file
72
docs/translation-glossary.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 翻译术语表 (Translation Glossary)
|
||||
|
||||
本文档为翻译贡献者提供项目中关键术语的标准翻译参考,以确保翻译的一致性和准确性。
|
||||
|
||||
This document provides standard translation references for key terminology in the project to ensure consistency and accuracy for translation contributors.
|
||||
|
||||
## 核心概念 (Core Concepts)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 倍率 | Ratio | 用于计算价格的乘数因子 | Multiplier factor used for price calculation |
|
||||
| 令牌 | Token | API访问凭证,也指模型处理的文本单元 | API access credentials or text units processed by models |
|
||||
| 渠道 | Channel | API服务提供商的接入通道 | Access channel for API service providers |
|
||||
| 分组 | Group | 用户或令牌的分类,影响价格倍率 | Classification of users or tokens, affecting price ratios |
|
||||
| 额度 | Quota | 用户可用的服务额度 | Available service quota for users |
|
||||
|
||||
## 模型相关 (Model Related)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 提示 | Prompt | 模型输入内容 | Model input content |
|
||||
| 补全 | Completion | 模型输出内容 | Model output content |
|
||||
| 输入 | Input/Prompt | 发送给模型的内容 | Content sent to the model |
|
||||
| 输出 | Output/Completion | 模型返回的内容 | Content returned by the model |
|
||||
| 模型倍率 | Model Ratio | 不同模型的计费倍率 | Billing ratio for different models |
|
||||
| 补全倍率 | Completion Ratio | 输出内容的额外计费倍率 | Additional billing ratio for output content |
|
||||
| 固定价格 | Price per call | 按次计费的价格 | Fixed price per call |
|
||||
| 按量计费 | Pay-as-you-go | 根据使用量计费 | Billing based on usage |
|
||||
| 按次计费 | Pay-per-view | 每次调用固定价格 | Fixed price per invocation |
|
||||
|
||||
## 用户管理 (User Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 超级管理员 | Root User | 最高权限管理员 | Administrator with highest privileges |
|
||||
| 管理员 | Admin User | 系统管理员 | System administrator |
|
||||
| 普通用户 | Normal User | 普通权限用户 | Regular user with standard privileges |
|
||||
|
||||
## 充值与兑换 (Recharge & Redemption)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 充值 | Top Up | 为账户增加额度 | Add quota to account |
|
||||
| 兑换码 | Redemption Code | 可兑换额度的代码 | Code that can be redeemed for quota |
|
||||
|
||||
## 渠道管理 (Channel Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 渠道 | Channel | API服务提供通道 | API service provider channel |
|
||||
| 密钥 | Key | API访问密钥 | API access key |
|
||||
| 优先级 | Priority | 渠道选择优先级 | Channel selection priority |
|
||||
| 权重 | Weight | 负载均衡权重 | Load balancing weight |
|
||||
| 代理 | Proxy | 代理服务器地址 | Proxy server address |
|
||||
| 模型重定向 | Model Mapping | 请求体中模型名称替换 | Model name replacement in request body |
|
||||
|
||||
## 翻译注意事项 (Translation Guidelines)
|
||||
|
||||
- **提示 (Prompt)** = 模型输入内容 / Model input content
|
||||
- **补全 (Completion)** = 模型输出内容 / Model output content
|
||||
- **倍率 (Ratio)** = 价格计算的乘数因子 / Multiplier factor for price calculation
|
||||
- **额度 (Quota)** = 可用的用户服务额度,有时也翻译为 Credit / Available service quota for users, sometimes also translated as Credit
|
||||
- **Token** = 根据上下文可能指 / Depending on context, may refer to:
|
||||
- API访问令牌 (API Token)
|
||||
- 模型处理的文本单元 (Text Token)
|
||||
- 系统访问令牌 (Access Token)
|
||||
|
||||
---
|
||||
|
||||
**贡献说明**: 如发现术语翻译不一致或有更好的翻译建议,欢迎提交 Issue 或 Pull Request。
|
||||
|
||||
**Contribution Note**: If you find any inconsistencies in terminology translations or have better translation suggestions, please feel free to submit an Issue or Pull Request.
|
||||
@@ -293,12 +293,13 @@ type GeminiChatSafetyRating struct {
|
||||
|
||||
type GeminiChatPromptFeedback struct {
|
||||
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
|
||||
BlockReason *string `json:"blockReason,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiChatResponse struct {
|
||||
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||
PromptFeedback GeminiChatPromptFeedback `json:"promptFeedback"`
|
||||
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
||||
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
|
||||
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
|
||||
@@ -74,14 +74,15 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 不能合并ExtraFields!!!!!!!!
|
||||
// 合并 ExtraFields
|
||||
for k, v := range r.Extra {
|
||||
if _, exists := baseMap[k]; !exists {
|
||||
baseMap[k] = v
|
||||
}
|
||||
}
|
||||
//for k, v := range r.Extra {
|
||||
// if _, exists := baseMap[k]; !exists {
|
||||
// baseMap[k] = v
|
||||
// }
|
||||
//}
|
||||
|
||||
return json.Marshal(baseMap)
|
||||
return common.Marshal(baseMap)
|
||||
}
|
||||
|
||||
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
|
||||
|
||||
73
electron/README.md
Normal file
73
electron/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# New API Electron Desktop App
|
||||
|
||||
This directory contains the Electron wrapper for New API, providing a native desktop application with system tray support for Windows, macOS, and Linux.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Go Binary (Required)
|
||||
The Electron app requires the compiled Go binary to function. You have two options:
|
||||
|
||||
**Option A: Use existing binary (without Go installed)**
|
||||
```bash
|
||||
# If you have a pre-built binary (e.g., new-api-macos)
|
||||
cp ../new-api-macos ../new-api
|
||||
```
|
||||
|
||||
**Option B: Build from source (requires Go)**
|
||||
TODO
|
||||
|
||||
### 3. Electron Dependencies
|
||||
```bash
|
||||
cd electron
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run the app in development mode:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
This will:
|
||||
- Start the Go backend on port 3000
|
||||
- Open an Electron window with DevTools enabled
|
||||
- Create a system tray icon (menu bar on macOS)
|
||||
- Store database in `../data/new-api.db`
|
||||
|
||||
## Building for Production
|
||||
|
||||
### Quick Build
|
||||
```bash
|
||||
# Ensure Go binary exists in parent directory
|
||||
ls ../new-api # Should exist
|
||||
|
||||
# Build for current platform
|
||||
npm run build
|
||||
|
||||
# Platform-specific builds
|
||||
npm run build:mac # Creates .dmg and .zip
|
||||
npm run build:win # Creates .exe installer
|
||||
npm run build:linux # Creates .AppImage and .deb
|
||||
```
|
||||
|
||||
### Build Output
|
||||
- Built applications are in `electron/dist/`
|
||||
- macOS: `.dmg` (installer) and `.zip` (portable)
|
||||
- Windows: `.exe` (installer) and portable exe
|
||||
- Linux: `.AppImage` and `.deb`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Port
|
||||
Default port is 3000. To change, edit `main.js`:
|
||||
```javascript
|
||||
const PORT = 3000; // Change to desired port
|
||||
```
|
||||
|
||||
### Database Location
|
||||
- **Development**: `../data/new-api.db` (project directory)
|
||||
- **Production**:
|
||||
- macOS: `~/Library/Application Support/New API/data/`
|
||||
- Windows: `%APPDATA%/New API/data/`
|
||||
- Linux: `~/.config/New API/data/`
|
||||
41
electron/build.sh
Executable file
41
electron/build.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "Building New API Electron App..."
|
||||
|
||||
echo "Step 1: Building frontend..."
|
||||
cd ../web
|
||||
DISABLE_ESLINT_PLUGIN='true' bun run build
|
||||
cd ../electron
|
||||
|
||||
echo "Step 2: Building Go backend..."
|
||||
cd ..
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "Building for macOS..."
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
|
||||
cd electron
|
||||
npm install
|
||||
npm run build:mac
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
echo "Building for Linux..."
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
|
||||
cd electron
|
||||
npm install
|
||||
npm run build:linux
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
|
||||
echo "Building for Windows..."
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api.exe
|
||||
cd electron
|
||||
npm install
|
||||
npm run build:win
|
||||
else
|
||||
echo "Unknown OS, building for current platform..."
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o new-api
|
||||
cd electron
|
||||
npm install
|
||||
npm run build
|
||||
fi
|
||||
|
||||
echo "Build complete! Check electron/dist/ for output."
|
||||
60
electron/create-tray-icon.js
Normal file
60
electron/create-tray-icon.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Create a simple tray icon for macOS
|
||||
// Run: node create-tray-icon.js
|
||||
|
||||
const fs = require('fs');
|
||||
const { createCanvas } = require('canvas');
|
||||
|
||||
function createTrayIcon() {
|
||||
// For macOS, we'll use a Template image (black and white)
|
||||
// Size should be 22x22 for Retina displays (@2x would be 44x44)
|
||||
const canvas = createCanvas(22, 22);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, 22, 22);
|
||||
|
||||
// Draw a simple "API" icon
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = 'bold 10px system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('API', 11, 11);
|
||||
|
||||
// Save as PNG
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync('tray-icon.png', buffer);
|
||||
|
||||
// For Template images on macOS (will adapt to menu bar theme)
|
||||
fs.writeFileSync('tray-iconTemplate.png', buffer);
|
||||
fs.writeFileSync('tray-iconTemplate@2x.png', buffer);
|
||||
|
||||
console.log('Tray icon created successfully!');
|
||||
}
|
||||
|
||||
// Check if canvas is installed
|
||||
try {
|
||||
createTrayIcon();
|
||||
} catch (err) {
|
||||
console.log('Canvas module not installed.');
|
||||
console.log('For now, creating a placeholder. Install canvas with: npm install canvas');
|
||||
|
||||
// Create a minimal 1x1 transparent PNG as placeholder
|
||||
const minimalPNG = Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xDB, 0x56,
|
||||
0xCA, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4C, 0x54,
|
||||
0x45, 0x00, 0x00, 0x00, 0xA7, 0x7A, 0x3D, 0xDA,
|
||||
0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4E, 0x53,
|
||||
0x00, 0x40, 0xE6, 0xD8, 0x66, 0x00, 0x00, 0x00,
|
||||
0x0A, 0x49, 0x44, 0x41, 0x54, 0x08, 0x1D, 0x62,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x01, 0x0A, 0x2D, 0xCB, 0x59, 0x00, 0x00,
|
||||
0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42,
|
||||
0x60, 0x82
|
||||
]);
|
||||
|
||||
fs.writeFileSync('tray-icon.png', minimalPNG);
|
||||
console.log('Created placeholder tray icon.');
|
||||
}
|
||||
18
electron/entitlements.mac.plist
Normal file
18
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
electron/icon.png
Normal file
BIN
electron/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
590
electron/main.js
Normal file
590
electron/main.js
Normal file
@@ -0,0 +1,590 @@
|
||||
const { app, BrowserWindow, dialog, Tray, Menu, shell } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
|
||||
let mainWindow;
|
||||
let serverProcess;
|
||||
let tray = null;
|
||||
let serverErrorLogs = [];
|
||||
const PORT = 3000;
|
||||
const DEV_FRONTEND_PORT = 5173; // Vite dev server port
|
||||
|
||||
// 保存日志到文件并打开
|
||||
function saveAndOpenErrorLog() {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFileName = `new-api-crash-${timestamp}.log`;
|
||||
const logDir = app.getPath('logs');
|
||||
const logFilePath = path.join(logDir, logFileName);
|
||||
|
||||
// 确保日志目录存在
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 写入日志
|
||||
const logContent = `New API 崩溃日志
|
||||
生成时间: ${new Date().toLocaleString('zh-CN')}
|
||||
平台: ${process.platform}
|
||||
架构: ${process.arch}
|
||||
应用版本: ${app.getVersion()}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
完整错误日志:
|
||||
|
||||
${serverErrorLogs.join('\n')}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
日志文件位置: ${logFilePath}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(logFilePath, logContent, 'utf8');
|
||||
|
||||
// 打开日志文件
|
||||
shell.openPath(logFilePath).then((error) => {
|
||||
if (error) {
|
||||
console.error('Failed to open log file:', error);
|
||||
// 如果打开文件失败,至少显示文件位置
|
||||
shell.showItemInFolder(logFilePath);
|
||||
}
|
||||
});
|
||||
|
||||
return logFilePath;
|
||||
} catch (err) {
|
||||
console.error('Failed to save error log:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 分析错误日志,识别常见错误并提供解决方案
|
||||
function analyzeError(errorLogs) {
|
||||
const allLogs = errorLogs.join('\n');
|
||||
|
||||
// 检测端口占用错误
|
||||
if (allLogs.includes('failed to start HTTP server') ||
|
||||
allLogs.includes('bind: address already in use') ||
|
||||
allLogs.includes('listen tcp') && allLogs.includes('bind: address already in use')) {
|
||||
return {
|
||||
type: '端口被占用',
|
||||
title: '端口 ' + PORT + ' 被占用',
|
||||
message: '无法启动服务器,端口已被其他程序占用',
|
||||
solution: `可能的解决方案:\n\n1. 关闭占用端口 ${PORT} 的其他程序\n2. 检查是否已经运行了另一个 New API 实例\n3. 使用以下命令查找占用端口的进程:\n Mac/Linux: lsof -i :${PORT}\n Windows: netstat -ano | findstr :${PORT}\n4. 重启电脑以释放端口`
|
||||
};
|
||||
}
|
||||
|
||||
// 检测数据库错误
|
||||
if (allLogs.includes('database is locked') ||
|
||||
allLogs.includes('unable to open database')) {
|
||||
return {
|
||||
type: '数据文件被占用',
|
||||
title: '无法访问数据文件',
|
||||
message: '应用的数据文件正被其他程序占用',
|
||||
solution: '可能的解决方案:\n\n1. 检查是否已经打开了另一个 New API 窗口\n - 查看任务栏/Dock 中是否有其他 New API 图标\n - 查看系统托盘(Windows)或菜单栏(Mac)中是否有 New API 图标\n\n2. 如果刚刚关闭过应用,请等待 10 秒后再试\n\n3. 重启电脑以释放被占用的文件\n\n4. 如果问题持续,可以尝试:\n - 退出所有 New API 实例\n - 删除数据目录中的临时文件(.db-shm 和 .db-wal)\n - 重新启动应用'
|
||||
};
|
||||
}
|
||||
|
||||
// 检测权限错误
|
||||
if (allLogs.includes('permission denied') ||
|
||||
allLogs.includes('access denied')) {
|
||||
return {
|
||||
type: '权限错误',
|
||||
title: '权限不足',
|
||||
message: '程序没有足够的权限执行操作',
|
||||
solution: '可能的解决方案:\n\n1. 以管理员/root权限运行程序\n2. 检查数据目录的读写权限\n3. 检查可执行文件的权限\n4. 在 Mac 上,检查安全性与隐私设置'
|
||||
};
|
||||
}
|
||||
|
||||
// 检测网络错误
|
||||
if (allLogs.includes('network is unreachable') ||
|
||||
allLogs.includes('no such host') ||
|
||||
allLogs.includes('connection refused')) {
|
||||
return {
|
||||
type: '网络错误',
|
||||
title: '网络连接失败',
|
||||
message: '无法建立网络连接',
|
||||
solution: '可能的解决方案:\n\n1. 检查网络连接是否正常\n2. 检查防火墙设置\n3. 检查代理配置\n4. 确认目标服务器地址正确'
|
||||
};
|
||||
}
|
||||
|
||||
// 检测配置文件错误
|
||||
if (allLogs.includes('invalid configuration') ||
|
||||
allLogs.includes('failed to parse config') ||
|
||||
allLogs.includes('yaml') || allLogs.includes('json') && allLogs.includes('parse')) {
|
||||
return {
|
||||
type: '配置错误',
|
||||
title: '配置文件错误',
|
||||
message: '配置文件格式不正确或包含无效配置',
|
||||
solution: '可能的解决方案:\n\n1. 检查配置文件格式是否正确\n2. 恢复默认配置\n3. 删除配置文件让程序重新生成\n4. 查看文档了解正确的配置格式'
|
||||
};
|
||||
}
|
||||
|
||||
// 检测内存不足
|
||||
if (allLogs.includes('out of memory') ||
|
||||
allLogs.includes('cannot allocate memory')) {
|
||||
return {
|
||||
type: '内存不足',
|
||||
title: '系统内存不足',
|
||||
message: '程序运行时内存不足',
|
||||
solution: '可能的解决方案:\n\n1. 关闭其他占用内存的程序\n2. 增加系统可用内存\n3. 重启电脑释放内存\n4. 检查是否存在内存泄漏'
|
||||
};
|
||||
}
|
||||
|
||||
// 检测文件不存在错误
|
||||
if (allLogs.includes('no such file or directory') ||
|
||||
allLogs.includes('cannot find the file')) {
|
||||
return {
|
||||
type: '文件缺失',
|
||||
title: '找不到必需的文件',
|
||||
message: '缺少程序运行所需的文件',
|
||||
solution: '可能的解决方案:\n\n1. 重新安装应用程序\n2. 检查安装目录是否完整\n3. 确保所有依赖文件都存在\n4. 检查文件路径是否正确'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBinaryPath() {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const platform = process.platform;
|
||||
|
||||
if (isDev) {
|
||||
const binaryName = platform === 'win32' ? 'new-api.exe' : 'new-api';
|
||||
return path.join(__dirname, '..', binaryName);
|
||||
}
|
||||
|
||||
let binaryName;
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
binaryName = 'new-api.exe';
|
||||
break;
|
||||
case 'darwin':
|
||||
binaryName = 'new-api';
|
||||
break;
|
||||
case 'linux':
|
||||
binaryName = 'new-api';
|
||||
break;
|
||||
default:
|
||||
binaryName = 'new-api';
|
||||
}
|
||||
|
||||
return path.join(process.resourcesPath, 'bin', binaryName);
|
||||
}
|
||||
|
||||
// Check if a server is available with retry logic
|
||||
function checkServerAvailability(port, maxRetries = 30, retryDelay = 1000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let currentAttempt = 0;
|
||||
|
||||
const tryConnect = () => {
|
||||
currentAttempt++;
|
||||
|
||||
if (currentAttempt % 5 === 1 && currentAttempt > 1) {
|
||||
console.log(`Attempting to connect to port ${port}... (attempt ${currentAttempt}/${maxRetries})`);
|
||||
}
|
||||
|
||||
const req = http.get({
|
||||
hostname: '127.0.0.1', // Use IPv4 explicitly instead of 'localhost' to avoid IPv6 issues
|
||||
port: port,
|
||||
timeout: 10000
|
||||
}, (res) => {
|
||||
// Server responded, connection successful
|
||||
req.destroy();
|
||||
console.log(`✓ Successfully connected to port ${port} (status: ${res.statusCode})`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
if (currentAttempt >= maxRetries) {
|
||||
reject(new Error(`Failed to connect to port ${port} after ${maxRetries} attempts: ${err.message}`));
|
||||
} else {
|
||||
setTimeout(tryConnect, retryDelay);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
if (currentAttempt >= maxRetries) {
|
||||
reject(new Error(`Connection timeout on port ${port} after ${maxRetries} attempts`));
|
||||
} else {
|
||||
setTimeout(tryConnect, retryDelay);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
tryConnect();
|
||||
});
|
||||
}
|
||||
|
||||
function startServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dataDir = path.join(userDataPath, 'data');
|
||||
|
||||
// 设置环境变量供 preload.js 使用
|
||||
process.env.ELECTRON_DATA_DIR = dataDir;
|
||||
|
||||
if (isDev) {
|
||||
// 开发模式:假设开发者手动启动了 Go 后端和前端开发服务器
|
||||
// 只需要等待前端开发服务器就绪
|
||||
console.log('Development mode: skipping server startup');
|
||||
console.log('Please make sure you have started:');
|
||||
console.log(' 1. Go backend: go run main.go (port 3000)');
|
||||
console.log(' 2. Frontend dev server: cd web && bun dev (port 5173)');
|
||||
console.log('');
|
||||
console.log('Checking if servers are running...');
|
||||
|
||||
// First check if both servers are accessible
|
||||
checkServerAvailability(DEV_FRONTEND_PORT)
|
||||
.then(() => {
|
||||
console.log('✓ Frontend dev server is accessible on port 5173');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`✗ Cannot connect to frontend dev server on port ${DEV_FRONTEND_PORT}`);
|
||||
console.error('Please make sure the frontend dev server is running:');
|
||||
console.error(' cd web && bun dev');
|
||||
reject(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生产模式:启动二进制服务器
|
||||
const env = { ...process.env, PORT: PORT.toString() };
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
env.SQLITE_PATH = path.join(dataDir, 'new-api.db');
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📁 您的数据存储位置:');
|
||||
console.log(' ' + dataDir);
|
||||
console.log(' 💡 备份提示:复制此目录即可备份所有数据');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
const binaryPath = getBinaryPath();
|
||||
const workingDir = process.resourcesPath;
|
||||
|
||||
console.log('Starting server from:', binaryPath);
|
||||
|
||||
serverProcess = spawn(binaryPath, [], {
|
||||
env,
|
||||
cwd: workingDir
|
||||
});
|
||||
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
console.log(`Server: ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
const errorMsg = data.toString();
|
||||
console.error(`Server Error: ${errorMsg}`);
|
||||
serverErrorLogs.push(errorMsg);
|
||||
// 只保留最近的100条错误日志
|
||||
if (serverErrorLogs.length > 100) {
|
||||
serverErrorLogs.shift();
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
serverProcess.on('close', (code) => {
|
||||
console.log(`Server process exited with code ${code}`);
|
||||
|
||||
// 如果退出代码不是0,说明服务器异常退出
|
||||
if (code !== 0 && code !== null) {
|
||||
const errorDetails = serverErrorLogs.length > 0
|
||||
? serverErrorLogs.slice(-20).join('\n')
|
||||
: '没有捕获到错误日志';
|
||||
|
||||
// 分析错误类型
|
||||
const knownError = analyzeError(serverErrorLogs);
|
||||
|
||||
let dialogOptions;
|
||||
if (knownError) {
|
||||
// 识别到已知错误,显示友好的错误信息和解决方案
|
||||
dialogOptions = {
|
||||
type: 'error',
|
||||
title: knownError.title,
|
||||
message: knownError.message,
|
||||
detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n退出代码: ${code}\n\n错误类型: ${knownError.type}\n\n最近的错误日志:\n${errorDetails}`,
|
||||
buttons: ['退出应用', '查看完整日志'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
};
|
||||
} else {
|
||||
// 未识别的错误,显示通用错误信息
|
||||
dialogOptions = {
|
||||
type: 'error',
|
||||
title: '服务器崩溃',
|
||||
message: '服务器进程异常退出',
|
||||
detail: `退出代码: ${code}\n\n最近的错误信息:\n${errorDetails}`,
|
||||
buttons: ['退出应用', '查看完整日志'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
};
|
||||
}
|
||||
|
||||
dialog.showMessageBox(dialogOptions).then((result) => {
|
||||
if (result.response === 1) {
|
||||
// 用户选择查看详情,保存并打开日志文件
|
||||
const logPath = saveAndOpenErrorLog();
|
||||
|
||||
// 显示确认对话框
|
||||
const confirmMessage = logPath
|
||||
? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。`
|
||||
: '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。';
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '日志已保存',
|
||||
message: confirmMessage,
|
||||
buttons: ['退出'],
|
||||
defaultId: 0
|
||||
}).then(() => {
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// 同时在控制台输出
|
||||
console.log('=== 完整错误日志 ===');
|
||||
console.log(serverErrorLogs.join('\n'));
|
||||
} else {
|
||||
// 用户选择直接退出
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 正常退出(code为0或null),直接关闭窗口
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkServerAvailability(PORT)
|
||||
.then(() => {
|
||||
console.log('✓ Backend server is accessible on port 3000');
|
||||
resolve();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('✗ Failed to connect to backend server');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const loadPort = isDev ? DEV_FRONTEND_PORT : PORT;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1080,
|
||||
height: 720,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
},
|
||||
title: 'New API',
|
||||
icon: path.join(__dirname, 'icon.png')
|
||||
});
|
||||
|
||||
mainWindow.loadURL(`http://127.0.0.1:${loadPort}`);
|
||||
|
||||
console.log(`Loading from: http://127.0.0.1:${loadPort}`);
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Close to tray instead of quitting
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!app.isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
// Use template icon for macOS (black with transparency, auto-adapts to theme)
|
||||
// Use colored icon for Windows
|
||||
const trayIconPath = process.platform === 'darwin'
|
||||
? path.join(__dirname, 'tray-iconTemplate.png')
|
||||
: path.join(__dirname, 'tray-icon-windows.png');
|
||||
|
||||
tray = new Tray(trayIconPath);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show New API',
|
||||
click: () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
tray.setToolTip('New API');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// On macOS, clicking the tray icon shows the window
|
||||
tray.on('click', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
} else {
|
||||
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
|
||||
if (mainWindow.isVisible() && process.platform === 'darwin') {
|
||||
app.dock.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
await startServer();
|
||||
createTray();
|
||||
createWindow();
|
||||
} catch (err) {
|
||||
console.error('Failed to start application:', err);
|
||||
|
||||
// 分析启动失败的错误
|
||||
const knownError = analyzeError(serverErrorLogs);
|
||||
|
||||
if (knownError) {
|
||||
dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: knownError.title,
|
||||
message: `启动失败: ${knownError.message}`,
|
||||
detail: `${knownError.solution}\n\n━━━━━━━━━━━━━━━━━━━━━━\n\n错误信息: ${err.message}\n\n错误类型: ${knownError.type}`,
|
||||
buttons: ['退出', '查看完整日志'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
}).then((result) => {
|
||||
if (result.response === 1) {
|
||||
// 用户选择查看日志
|
||||
const logPath = saveAndOpenErrorLog();
|
||||
|
||||
const confirmMessage = logPath
|
||||
? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。`
|
||||
: '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。';
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '日志已保存',
|
||||
message: confirmMessage,
|
||||
buttons: ['退出'],
|
||||
defaultId: 0
|
||||
}).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
console.log('=== 完整错误日志 ===');
|
||||
console.log(serverErrorLogs.join('\n'));
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: '启动失败',
|
||||
message: '无法启动服务器',
|
||||
detail: `错误信息: ${err.message}\n\n请检查日志获取更多信息。`,
|
||||
buttons: ['退出', '查看完整日志'],
|
||||
defaultId: 0,
|
||||
cancelId: 0
|
||||
}).then((result) => {
|
||||
if (result.response === 1) {
|
||||
// 用户选择查看日志
|
||||
const logPath = saveAndOpenErrorLog();
|
||||
|
||||
const confirmMessage = logPath
|
||||
? `日志已保存到:\n${logPath}\n\n日志文件已在默认文本编辑器中打开。\n\n点击"退出"关闭应用程序。`
|
||||
: '日志保存失败,但已在控制台输出。\n\n点击"退出"关闭应用程序。';
|
||||
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '日志已保存',
|
||||
message: confirmMessage,
|
||||
buttons: ['退出'],
|
||||
defaultId: 0
|
||||
}).then(() => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
console.log('=== 完整错误日志 ===');
|
||||
console.log(serverErrorLogs.join('\n'));
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Don't quit when window is closed, keep running in tray
|
||||
// Only quit when explicitly choosing Quit from tray menu
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (serverProcess) {
|
||||
event.preventDefault();
|
||||
|
||||
console.log('Shutting down server...');
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
setTimeout(() => {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
app.exit();
|
||||
}, 5000);
|
||||
|
||||
serverProcess.on('close', () => {
|
||||
serverProcess = null;
|
||||
app.exit();
|
||||
});
|
||||
}
|
||||
});
|
||||
3677
electron/package-lock.json
generated
Normal file
3677
electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
101
electron/package.json
Normal file
101
electron/package.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "new-api-electron",
|
||||
"version": "1.0.0",
|
||||
"description": "New API - AI Model Gateway Desktop Application",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start-app": "electron .",
|
||||
"dev-app": "cross-env NODE_ENV=development electron .",
|
||||
"build": "electron-builder",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:linux": "electron-builder --linux"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"api",
|
||||
"gateway",
|
||||
"openai",
|
||||
"claude"
|
||||
],
|
||||
"author": "QuantumNous",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/QuantumNous/new-api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "28.3.3",
|
||||
"electron-builder": "^24.9.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.newapi.desktop",
|
||||
"productName": "New-API-App",
|
||||
"publish": null,
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"icon.png",
|
||||
"tray-iconTemplate.png",
|
||||
"tray-iconTemplate@2x.png",
|
||||
"tray-icon-windows.png"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"icon": "icon.png",
|
||||
"identity": null,
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "entitlements.mac.plist",
|
||||
"entitlementsInherit": "entitlements.mac.plist",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../new-api",
|
||||
"to": "bin/new-api"
|
||||
},
|
||||
{
|
||||
"from": "../web/dist",
|
||||
"to": "web/dist"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "icon.png",
|
||||
"target": [
|
||||
"nsis",
|
||||
"portable"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../new-api.exe",
|
||||
"to": "bin/new-api.exe"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "icon.png",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Development",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../new-api",
|
||||
"to": "bin/new-api"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
18
electron/preload.js
Normal file
18
electron/preload.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { contextBridge } = require('electron');
|
||||
|
||||
// 获取数据目录路径(用于显示给用户)
|
||||
// 优先使用主进程设置的真实路径,如果没有则回退到手动拼接
|
||||
function getDataDirPath() {
|
||||
// 如果主进程已设置真实路径,直接使用
|
||||
if (process.env.ELECTRON_DATA_DIR) {
|
||||
return process.env.ELECTRON_DATA_DIR;
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
isElectron: true,
|
||||
version: process.versions.electron,
|
||||
platform: process.platform,
|
||||
versions: process.versions,
|
||||
dataDir: getDataDirPath()
|
||||
});
|
||||
BIN
electron/tray-icon-windows.png
Normal file
BIN
electron/tray-icon-windows.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
electron/tray-iconTemplate.png
Normal file
BIN
electron/tray-iconTemplate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 459 B |
BIN
electron/tray-iconTemplate@2x.png
Normal file
BIN
electron/tray-iconTemplate@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 754 B |
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"one-api/common"
|
||||
"one-api/setting/operation_setting"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
|
||||
}
|
||||
|
||||
func LogQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
// 新逻辑:根据额度展示类型输出
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f 额度", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f 额度", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d 点额度", quota)
|
||||
default: // USD
|
||||
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d", quota)
|
||||
default:
|
||||
return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
case "LogConsumeEnabled":
|
||||
common.LogConsumeEnabled = boolValue
|
||||
case "DisplayInCurrencyEnabled":
|
||||
common.DisplayInCurrencyEnabled = boolValue
|
||||
// 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效)
|
||||
// true -> USD, false -> TOKENS
|
||||
newVal := "USD"
|
||||
if !boolValue {
|
||||
newVal = "TOKENS"
|
||||
}
|
||||
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
|
||||
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
|
||||
}
|
||||
case "DisplayTokenStatEnabled":
|
||||
common.DisplayTokenStatEnabled = boolValue
|
||||
case "DrawingEnabled":
|
||||
|
||||
@@ -961,9 +961,15 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
if response.IsToolCall() {
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 1)
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0] = *response.GetFirstToolCall()
|
||||
emptyResponse.Choices[0].Delta.ToolCalls[0].Function.Arguments = ""
|
||||
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
|
||||
toolCalls := response.Choices[0].Delta.ToolCalls
|
||||
copiedToolCalls := make([]dto.ToolCallResponse, len(toolCalls))
|
||||
for idx := range toolCalls {
|
||||
copiedToolCalls[idx] = toolCalls[idx]
|
||||
copiedToolCalls[idx].Function.Arguments = ""
|
||||
}
|
||||
emptyResponse.Choices[0].Delta.ToolCalls = copiedToolCalls
|
||||
}
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
err = handleStream(c, info, emptyResponse)
|
||||
if err != nil {
|
||||
@@ -1044,7 +1050,12 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
//return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||
return nil, types.NewOpenAIError(errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), types.ErrorCodePromptBlocked, http.StatusBadRequest)
|
||||
} else {
|
||||
return nil, types.NewOpenAIError(errors.New("empty response from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
|
||||
fullTextResponse.Model = info.UpstreamModelName
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||
openaiAdaptor := openai.Adaptor{}
|
||||
@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings {
|
||||
return info.ChannelBaseUrl + "/api/embed", nil
|
||||
}
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return info.ChannelBaseUrl + "/api/generate", nil
|
||||
}
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request == nil { return nil, errors.New("request is nil") }
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// decide generate or chat
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return openAIToGenerate(c, request)
|
||||
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
return requestOpenAI2Embeddings(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
)
|
||||
|
||||
type OllamaChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaToolFunction struct {
|
||||
@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
|
||||
}
|
||||
|
||||
type OllamaTool struct {
|
||||
Type string `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Function OllamaToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
|
||||
}
|
||||
|
||||
type OllamaGenerateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
|
||||
// options mapping
|
||||
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
|
||||
if r.Temperature != nil {
|
||||
chatReq.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
chatReq.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
chatReq.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
chatReq.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
chatReq.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
chatReq.Options["num_predict"] = int(mt)
|
||||
}
|
||||
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
@@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
case []string:
|
||||
chatReq.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string,0,len(v))
|
||||
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
|
||||
if len(arr)>0 { chatReq.Options["stop"] = arr }
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
chatReq.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Tools) > 0 {
|
||||
tools := make([]OllamaTool,0,len(r.Tools))
|
||||
tools := make([]OllamaTool, 0, len(r.Tools))
|
||||
for _, t := range r.Tools {
|
||||
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
||||
}
|
||||
chatReq.Tools = tools
|
||||
}
|
||||
|
||||
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
|
||||
chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages))
|
||||
for _, m := range r.Messages {
|
||||
var textBuilder strings.Builder
|
||||
var images []string
|
||||
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
var base64Data string
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base64Data = fileData.Base64Data
|
||||
} else if strings.HasPrefix(img.Url, "data:") {
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
|
||||
base64Data = img.Url[idx+1:]
|
||||
}
|
||||
} else {
|
||||
base64Data = img.Url
|
||||
}
|
||||
if base64Data != "" { images = append(images, base64Data) }
|
||||
if base64Data != "" {
|
||||
images = append(images, base64Data)
|
||||
}
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeText {
|
||||
textBuilder.WriteString(part.Text)
|
||||
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
}
|
||||
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
||||
if len(images)>0 { cm.Images = images }
|
||||
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
|
||||
if len(images) > 0 {
|
||||
cm.Images = images
|
||||
}
|
||||
if m.Role == "tool" && m.Name != nil {
|
||||
cm.ToolName = *m.Name
|
||||
}
|
||||
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
||||
parsed := m.ParseToolCalls()
|
||||
if len(parsed) > 0 {
|
||||
calls := make([]OllamaToolCall,0,len(parsed))
|
||||
calls := make([]OllamaToolCall, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
var args interface{}
|
||||
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
|
||||
if args==nil { args = map[string]any{} }
|
||||
if tc.Function.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
oc := OllamaToolCall{}
|
||||
oc.Function.Name = tc.Function.Name
|
||||
oc.Function.Arguments = args
|
||||
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
gen.Prompt = v
|
||||
case []any:
|
||||
var sb strings.Builder
|
||||
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
|
||||
for _, it := range v {
|
||||
if s, ok := it.(string); ok {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
gen.Prompt = sb.String()
|
||||
default:
|
||||
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
||||
}
|
||||
}
|
||||
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
|
||||
if r.Suffix != nil {
|
||||
if s, ok := r.Suffix.(string); ok {
|
||||
gen.Suffix = s
|
||||
}
|
||||
}
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" {
|
||||
gen.Format = "json"
|
||||
} else if r.ResponseFormat.Type == "json_schema" {
|
||||
var schema any
|
||||
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
|
||||
gen.Format = schema
|
||||
}
|
||||
}
|
||||
if r.Temperature != nil {
|
||||
gen.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
gen.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
gen.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
gen.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
gen.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
gen.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
gen.Options["num_predict"] = int(mt)
|
||||
}
|
||||
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
case string: gen.Options["stop"] = []string{v}
|
||||
case []string: gen.Options["stop"] = v
|
||||
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
|
||||
case string:
|
||||
gen.Options["stop"] = []string{v}
|
||||
case []string:
|
||||
gen.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
gen.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
return gen, nil
|
||||
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
|
||||
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
||||
opts := map[string]any{}
|
||||
if r.Temperature != nil { opts["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { opts["top_p"] = r.TopP }
|
||||
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
|
||||
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
|
||||
if r.Temperature != nil {
|
||||
opts["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
opts["top_p"] = r.TopP
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
opts["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
opts["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
opts["seed"] = int(r.Seed)
|
||||
}
|
||||
if r.Dimensions != 0 {
|
||||
opts["dimensions"] = r.Dimensions
|
||||
}
|
||||
input := r.ParseInput()
|
||||
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
|
||||
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
|
||||
if len(input) == 1 {
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
|
||||
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
var oResp OllamaEmbeddingResponse
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
|
||||
if err = common.Unmarshal(body, &oResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if oResp.Error != "" {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings {
|
||||
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
|
||||
}
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
|
||||
out, _ := common.Marshal(embResp)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,210 +1,278 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ollamaChatStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
}
|
||||
|
||||
func toUnix(ts string) int64 {
|
||||
if ts == "" { return time.Now().Unix() }
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
|
||||
return t.Unix()
|
||||
if ts == "" {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
t2, err2 := time.Parse(time.RFC3339, ts)
|
||||
if err2 == nil {
|
||||
return t2.Unix()
|
||||
}
|
||||
return time.Now().Unix()
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
|
||||
}
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" { continue }
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" { model = chunk.Model }
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" {
|
||||
model = chunk.Model
|
||||
}
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
|
||||
} },
|
||||
}
|
||||
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
|
||||
return usage, nil
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil {
|
||||
content = chunk.Message.Content
|
||||
} else {
|
||||
content = chunk.Response
|
||||
}
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
|
||||
}},
|
||||
}
|
||||
if content != "" {
|
||||
delta.Choices[0].Delta.SetContentString(content)
|
||||
}
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||
}
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
logger.LogError(c, "ollama stream scan error: "+err.Error())
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// non-stream handler for chat/generate
|
||||
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled {
|
||||
println("ollama non-stream raw resp:", raw)
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" { continue }
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
|
||||
}
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" {
|
||||
aggContent.WriteString(ck.Message.Content)
|
||||
} else if ck.Response != "" {
|
||||
aggContent.WriteString(ck.Response)
|
||||
}
|
||||
}
|
||||
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else { aggContent.WriteString(single.Response) }
|
||||
}
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(single.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else {
|
||||
aggContent.WriteString(single.Response)
|
||||
}
|
||||
}
|
||||
|
||||
model := lastChunk.Model
|
||||
if model == "" { model = info.UpstreamModelName }
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
model := lastChunk.Model
|
||||
if model == "" {
|
||||
model = info.UpstreamModelName
|
||||
}
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{ {
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
} },
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" {
|
||||
msg.ReasoningContent = rc
|
||||
}
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
|
||||
func contentPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
// SiliconFlow requires messages array for FIM requests, even if client doesn't send it
|
||||
if (request.Prefix != nil || request.Suffix != nil) && len(request.Messages) == 0 {
|
||||
// Add an empty user message to satisfy SiliconFlow's requirement
|
||||
request.Messages = []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,4 @@ var ModelList = []string{
|
||||
"deepseek-ai/DeepSeek-V3.1",
|
||||
}
|
||||
|
||||
const ChannelName = "submodel"
|
||||
const ChannelName = "submodel"
|
||||
|
||||
@@ -275,7 +275,9 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
|
||||
return nil, errors.New("field prompt is required")
|
||||
}
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
if len(textRequest.Messages) == 0 {
|
||||
// For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional
|
||||
// It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek)
|
||||
if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil {
|
||||
return nil, errors.New("field messages is required")
|
||||
}
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
|
||||
@@ -636,9 +636,6 @@ func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
|
||||
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||
geminiResponse := &dto.GeminiChatResponse{
|
||||
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||
PromptFeedback: dto.GeminiChatPromptFeedback{
|
||||
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: openAIResponse.PromptTokens,
|
||||
CandidatesTokenCount: openAIResponse.CompletionTokens,
|
||||
@@ -735,9 +732,6 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
|
||||
geminiResponse := &dto.GeminiChatResponse{
|
||||
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||
PromptFeedback: dto.GeminiChatPromptFeedback{
|
||||
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||
},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: info.PromptTokens,
|
||||
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
|
||||
|
||||
@@ -2,17 +2,34 @@ package operation_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
// 额度展示类型
|
||||
const (
|
||||
QuotaDisplayTypeUSD = "USD"
|
||||
QuotaDisplayTypeCNY = "CNY"
|
||||
QuotaDisplayTypeTokens = "TOKENS"
|
||||
QuotaDisplayTypeCustom = "CUSTOM"
|
||||
)
|
||||
|
||||
type GeneralSetting struct {
|
||||
DocsLink string `json:"docs_link"`
|
||||
PingIntervalEnabled bool `json:"ping_interval_enabled"`
|
||||
PingIntervalSeconds int `json:"ping_interval_seconds"`
|
||||
// 当前站点额度展示类型:USD / CNY / TOKENS
|
||||
QuotaDisplayType string `json:"quota_display_type"`
|
||||
// 自定义货币符号,用于 CUSTOM 展示类型
|
||||
CustomCurrencySymbol string `json:"custom_currency_symbol"`
|
||||
// 自定义货币与美元汇率(1 USD = X Custom)
|
||||
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var generalSetting = GeneralSetting{
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
QuotaDisplayType: QuotaDisplayTypeUSD,
|
||||
CustomCurrencySymbol: "¤",
|
||||
CustomCurrencyExchangeRate: 1.0,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -23,3 +40,52 @@ func init() {
|
||||
func GetGeneralSetting() *GeneralSetting {
|
||||
return &generalSetting
|
||||
}
|
||||
|
||||
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
|
||||
func IsCurrencyDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
|
||||
}
|
||||
|
||||
// IsCNYDisplay 是否以人民币展示
|
||||
func IsCNYDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
|
||||
}
|
||||
|
||||
// GetQuotaDisplayType 返回额度展示类型
|
||||
func GetQuotaDisplayType() string {
|
||||
return generalSetting.QuotaDisplayType
|
||||
}
|
||||
|
||||
// GetCurrencySymbol 返回当前展示类型对应符号
|
||||
func GetCurrencySymbol() string {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return "$"
|
||||
case QuotaDisplayTypeCNY:
|
||||
return "¥"
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencySymbol != "" {
|
||||
return generalSetting.CustomCurrencySymbol
|
||||
}
|
||||
return "¤"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 X(TOKENS 不适用)
|
||||
func GetUsdToCurrencyRate(usdToCny float64) float64 {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return 1
|
||||
case QuotaDisplayTypeCNY:
|
||||
return usdToCny
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencyExchangeRate > 0 {
|
||||
return generalSetting.CustomCurrencyExchangeRate
|
||||
}
|
||||
return 1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const (
|
||||
ErrorCodeEmptyResponse ErrorCode = "empty_response"
|
||||
ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
|
||||
ErrorCodeModelNotFound ErrorCode = "model_not_found"
|
||||
ErrorCodePromptBlocked ErrorCode = "prompt_blocked"
|
||||
|
||||
// sql error
|
||||
ErrorCodeQueryDataError ErrorCode = "query_data_error"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
<analytics></analytics>
|
||||
<analytics></analytics>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -42,7 +42,7 @@ const OperationSetting = () => {
|
||||
QuotaPerUnit: 0,
|
||||
USDExchangeRate: 0,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: false,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
|
||||
@@ -91,7 +91,8 @@ const AccountManagement = ({
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||
React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
@@ -236,7 +237,8 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.github_id) || !status.github_oauth
|
||||
isBound(userState.user?.github_id) ||
|
||||
!status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -394,7 +396,8 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||
isBound(userState.user?.linux_do_id) ||
|
||||
!status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
|
||||
@@ -25,29 +25,54 @@ import { Banner } from '@douyinfe/semi-ui';
|
||||
* 显示当前数据库类型和相关警告信息
|
||||
*/
|
||||
const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
|
||||
// 检测是否在 Electron 环境中运行
|
||||
const isElectron = typeof window !== 'undefined' && window.electron?.isElectron;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 数据库警告 */}
|
||||
{setupStatus.database_type === 'sqlite' && (
|
||||
<Banner
|
||||
type='warning'
|
||||
type={isElectron ? 'info' : 'warning'}
|
||||
closeIcon={null}
|
||||
title={t('数据库警告')}
|
||||
title={isElectron ? t('本地数据存储') : t('数据库警告')}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
<strong>
|
||||
isElectron ? (
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
'您的数据将安全地存储在本地计算机上。所有配置、用户信息和使用记录都会自动保存,关闭应用后不会丢失。',
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
{window.electron?.dataDir && (
|
||||
<p className='mt-2 text-sm opacity-80'>
|
||||
<strong>{t('数据存储位置:')}</strong>
|
||||
<br />
|
||||
<code className='bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded'>
|
||||
{window.electron.dataDir}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<p className='mt-2 text-sm opacity-70'>
|
||||
💡 {t('提示:如需备份数据,只需复制上述目录即可')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
<strong>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
className='!rounded-lg'
|
||||
fullMode={false}
|
||||
|
||||
@@ -91,8 +91,7 @@ const REGION_EXAMPLE = {
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48,
|
||||
43,
|
||||
1, 4, 14, 34, 17, 26, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
@@ -408,7 +407,10 @@ const EditChannelModal = (props) => {
|
||||
break;
|
||||
case 45:
|
||||
localModels = getChannelModels(value);
|
||||
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
|
||||
setInputs((prevInputs) => ({
|
||||
...prevInputs,
|
||||
base_url: 'https://ark.cn-beijing.volces.com',
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
@@ -502,7 +504,8 @@ const EditChannelModal = (props) => {
|
||||
// 读取 Vertex 密钥格式
|
||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||
// 读取企业账户设置
|
||||
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
||||
data.is_enterprise_account =
|
||||
parsedSettings.openrouter_enterprise === true;
|
||||
// 读取字段透传控制设置
|
||||
data.allow_service_tier = parsedSettings.allow_service_tier || false;
|
||||
data.disable_store = parsedSettings.disable_store || false;
|
||||
@@ -929,7 +932,10 @@ const EditChannelModal = (props) => {
|
||||
showInfo(t('请至少选择一个模型!'));
|
||||
return;
|
||||
}
|
||||
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
|
||||
if (
|
||||
localInputs.type === 45 &&
|
||||
(!localInputs.base_url || localInputs.base_url.trim() === '')
|
||||
) {
|
||||
showInfo(t('请输入API地址!'));
|
||||
return;
|
||||
}
|
||||
@@ -974,7 +980,8 @@ const EditChannelModal = (props) => {
|
||||
|
||||
// type === 20: 设置企业账户标识,无论是true还是false都要传到后端
|
||||
if (localInputs.type === 20) {
|
||||
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
||||
settings.openrouter_enterprise =
|
||||
localInputs.is_enterprise_account === true;
|
||||
}
|
||||
|
||||
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
|
||||
@@ -1433,7 +1440,9 @@ const EditChannelModal = (props) => {
|
||||
setIsEnterpriseAccount(value);
|
||||
handleInputChange('is_enterprise_account', value);
|
||||
}}
|
||||
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
||||
extraText={t(
|
||||
'企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选',
|
||||
)}
|
||||
initValue={inputs.is_enterprise_account}
|
||||
/>
|
||||
)}
|
||||
@@ -2061,27 +2070,27 @@ const EditChannelModal = (props) => {
|
||||
)}
|
||||
|
||||
{inputs.type === 45 && (
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com'
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com'
|
||||
}
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com',
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com',
|
||||
},
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
|
||||
@@ -107,6 +107,7 @@ const SearchActions = memo(
|
||||
optionList={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'CNY', label: 'CNY' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -60,38 +60,54 @@ const ContentModal = ({
|
||||
if (videoError) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px' }}
|
||||
>
|
||||
视频无法在当前浏览器中播放,这可能是由于:
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 视频服务商的跨域限制
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 需要特定的请求头或认证
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
|
||||
>
|
||||
• 防盗链保护机制
|
||||
</Text>
|
||||
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
<Button
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
在新标签页中打开
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<Text
|
||||
type="tertiary"
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||
>
|
||||
{modalContent}
|
||||
@@ -104,22 +120,24 @@ const ContentModal = ({
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
autoPlay
|
||||
crossOrigin="anonymous"
|
||||
crossOrigin='anonymous'
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
@@ -134,10 +152,10 @@ const ContentModal = ({
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px'
|
||||
padding: isVideo && videoError ? '0' : '24px',
|
||||
}}
|
||||
width={800}
|
||||
>
|
||||
|
||||
@@ -419,7 +419,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT,
|
||||
title: t('提示'),
|
||||
title: t('输入'),
|
||||
dataIndex: 'prompt_tokens',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
@@ -431,7 +431,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.COMPLETION,
|
||||
title: t('补全'),
|
||||
title: t('输出'),
|
||||
dataIndex: 'completion_tokens',
|
||||
render: (text, record, index) => {
|
||||
return parseInt(text) > 0 &&
|
||||
|
||||
@@ -37,6 +37,7 @@ import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
import { IconGift } from '@douyinfe/semi-icons';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -338,7 +339,23 @@ const RechargeCard = ({
|
||||
)}
|
||||
|
||||
{(enableOnlineTopUp || enableStripeTopUp) && (
|
||||
<Form.Slot label={t('选择充值额度')}>
|
||||
<Form.Slot
|
||||
label={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{t('选择充值额度')}</span>
|
||||
{(() => {
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
if (type === 'USD') return null;
|
||||
|
||||
return (
|
||||
<span style={{ color: 'var(--semi-color-text-2)', fontSize: '12px', fontWeight: 'normal' }}>
|
||||
(1 $ = {rate.toFixed(2)} {symbol})
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount =
|
||||
@@ -351,6 +368,35 @@ const RechargeCard = ({
|
||||
const actualPay = discountedPrice;
|
||||
const save = originalPrice - discountedPrice;
|
||||
|
||||
// 根据当前货币类型换算显示金额和数量
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 7; // 默认CNY汇率
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let displayValue = preset.value; // 显示的数量
|
||||
let displayActualPay = actualPay;
|
||||
let displaySave = save;
|
||||
|
||||
if (type === 'USD') {
|
||||
// 数量保持USD,价格从CNY转USD
|
||||
displayActualPay = actualPay / usdRate;
|
||||
displaySave = save / usdRate;
|
||||
} else if (type === 'CNY') {
|
||||
// 数量转CNY,价格已是CNY
|
||||
displayValue = preset.value * usdRate;
|
||||
} else if (type === 'CUSTOM') {
|
||||
// 数量和价格都转自定义货币
|
||||
displayValue = preset.value * rate;
|
||||
displayActualPay = (actualPay / usdRate) * rate;
|
||||
displaySave = (save / usdRate) * rate;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -378,7 +424,7 @@ const RechargeCard = ({
|
||||
style={{ margin: '0 0 8px 0' }}
|
||||
>
|
||||
<Coins size={18} />
|
||||
{formatLargeNumber(preset.value)}
|
||||
{formatLargeNumber(preset.value)} $
|
||||
{hasDiscount && (
|
||||
<Tag style={{ marginLeft: 4 }} color='green'>
|
||||
{t('折').includes('off')
|
||||
@@ -398,10 +444,10 @@ const RechargeCard = ({
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{t('实付')} {actualPay.toFixed(2)},
|
||||
{t('实付')} {symbol}{displayActualPay.toFixed(2)},
|
||||
{hasDiscount
|
||||
? `${t('节省')} ${save.toFixed(2)}`
|
||||
: `${t('节省')} 0.00`}
|
||||
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
|
||||
: `${t('节省')} ${symbol}0.00`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -23,7 +23,9 @@ export function setStatusData(data) {
|
||||
localStorage.setItem('logo', data.logo);
|
||||
localStorage.setItem('footer_html', data.footer_html);
|
||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||
// 兼容:保留旧字段,同时写入新的额度展示类型
|
||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
|
||||
localStorage.setItem('enable_drawing', data.enable_drawing);
|
||||
localStorage.setItem('enable_task', data.enable_task);
|
||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||
|
||||
@@ -832,12 +832,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return 0;
|
||||
}
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
num = num.toFixed(digits);
|
||||
if (displayInCurrency) {
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + num;
|
||||
} else if (quotaDisplayType === 'USD') {
|
||||
return '$' + num;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + num;
|
||||
} else {
|
||||
return num;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
export function renderNumberWithPoint(num) {
|
||||
@@ -889,33 +902,111 @@ export function getQuotaWithUnit(quota, digits = 6) {
|
||||
}
|
||||
|
||||
export function renderQuotaWithAmount(amount) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
return '$' + amount;
|
||||
} else {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(renderUnitWithQuota(amount));
|
||||
}
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + amount;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + amount;
|
||||
}
|
||||
return '$' + amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前货币配置信息
|
||||
* @returns {Object} - { symbol, rate, type }
|
||||
*/
|
||||
export function getCurrencyConfig() {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
const statusStr = localStorage.getItem('status');
|
||||
|
||||
let symbol = '$';
|
||||
let rate = 1;
|
||||
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
symbol = '¥';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
rate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
rate = s?.custom_currency_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return { symbol, rate, type: quotaDisplayType };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将美元金额转换为当前选择的货币
|
||||
* @param {number} usdAmount - 美元金额
|
||||
* @param {number} digits - 小数位数
|
||||
* @returns {string} - 格式化后的货币字符串
|
||||
*/
|
||||
export function convertUSDToCurrency(usdAmount, digits = 2) {
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const convertedAmount = usdAmount * rate;
|
||||
return symbol + convertedAmount.toFixed(digits);
|
||||
}
|
||||
|
||||
export function renderQuota(quota, digits = 2) {
|
||||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const result = quota / quotaPerUnit;
|
||||
const fixedResult = result.toFixed(digits);
|
||||
|
||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return '$' + minValue.toFixed(digits);
|
||||
}
|
||||
|
||||
return '$' + fixedResult;
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(quota);
|
||||
}
|
||||
return renderNumber(quota);
|
||||
const resultUSD = quota / quotaPerUnit;
|
||||
let symbol = '$';
|
||||
let value = resultUSD;
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * usdRate;
|
||||
symbol = '¥';
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbolCustom = '¤';
|
||||
let rate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbolCustom = s?.custom_currency_symbol || symbolCustom;
|
||||
rate = s?.custom_currency_exchange_rate || rate;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * rate;
|
||||
symbol = symbolCustom;
|
||||
}
|
||||
const fixedResult = value.toFixed(digits);
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return symbol + minValue.toFixed(digits);
|
||||
}
|
||||
return symbol + fixedResult;
|
||||
}
|
||||
|
||||
function isValidGroupRatio(ratio) {
|
||||
@@ -1037,14 +1128,20 @@ export function renderModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
const displayPrice = (modelPrice * rate).toFixed(6);
|
||||
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: displayPrice,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: displayTotal,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1080,19 +1177,21 @@ export function renderModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 $${audioInputPrice} / 1M tokens`
|
||||
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
|
||||
: '',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1100,10 +1199,11 @@ export function renderModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1112,11 +1212,12 @@ export function renderModelPrice(
|
||||
{image && imageOutputTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
{
|
||||
price: imageRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (imageRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: imageRatioPrice * groupRatio,
|
||||
total: (imageRatioPrice * groupRatio * rate).toFixed(6),
|
||||
imageRatio: imageRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1124,22 +1225,25 @@ export function renderModelPrice(
|
||||
)}
|
||||
{webSearch && webSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('Web搜索价格:${{price}} / 1K 次', {
|
||||
price: webSearchPrice,
|
||||
{i18next.t('Web搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{fileSearch && fileSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('文件搜索价格:${{price}} / 1K 次', {
|
||||
price: fileSearchPrice,
|
||||
{i18next.t('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||
<p>
|
||||
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||
price: imageGenerationCallPrice,
|
||||
{i18next.t('图片生成调用:{{symbol}}{{price}} / 1次', {
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -1149,50 +1253,55 @@ export function renderModelPrice(
|
||||
let inputDesc = '';
|
||||
if (image && imageOutputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
nonImageInput: inputTokens - imageOutputTokens,
|
||||
imageInput: imageOutputTokens,
|
||||
imageRatio: imageRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (cacheTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
price: inputRatioPrice,
|
||||
cachePrice: cacheRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
|
||||
{
|
||||
nonAudioInput: inputTokens - audioInputTokens,
|
||||
audioInput: audioInputTokens,
|
||||
price: inputRatioPrice,
|
||||
audioPrice: audioInputPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: (audioInputPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
|
||||
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建输出部分描述
|
||||
const outputDesc = i18next.t(
|
||||
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1202,10 +1311,11 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1213,10 +1323,11 @@ export function renderModelPrice(
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1224,9 +1335,10 @@ export function renderModelPrice(
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
price: imageGenerationCallPrice,
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1235,12 +1347,13 @@ export function renderModelPrice(
|
||||
].join('');
|
||||
|
||||
return i18next.t(
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
|
||||
{
|
||||
inputDesc,
|
||||
outputDesc,
|
||||
extraServices,
|
||||
total: price.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
})()}
|
||||
@@ -1271,10 +1384,14 @@ export function renderLogContent(
|
||||
label: ratioLabel,
|
||||
useUserGroupRatio: useUserGroupRatio,
|
||||
} = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio,
|
||||
});
|
||||
@@ -1367,14 +1484,19 @@ export function renderAudioModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
// 1 ratio = $0.002 / 1K tokens
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1409,16 +1531,18 @@ export function renderAudioModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1426,10 +1550,11 @@ export function renderAudioModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1437,20 +1562,22 @@ export function renderAudioModelPrice(
|
||||
)}
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
'音频提示价格:{{symbol}}{{price}} * {{audioRatio}} = {{symbol}}{{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * rate).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
'音频补全价格:{{symbol}}{{price}} * {{audioRatio}} * {{audioCompRatio}} = {{symbol}}{{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio * audioCompletionRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
audioCompRatio: audioCompletionRatio,
|
||||
},
|
||||
@@ -1459,48 +1586,52 @@ export function renderAudioModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0
|
||||
? i18next.t(
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cachePrice: inputRatioPrice * cacheRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}',
|
||||
'音频提示 {{input}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: audioInputTokens,
|
||||
completion: audioCompletionTokens,
|
||||
audioInputPrice: audioRatio * inputRatioPrice,
|
||||
symbol: symbol,
|
||||
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
|
||||
audioCompPrice:
|
||||
audioRatio * audioCompletionRatio * inputRatioPrice,
|
||||
total: audioPrice.toFixed(6),
|
||||
(audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
|
||||
total: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}',
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
total: price.toFixed(6),
|
||||
textPrice: textPrice.toFixed(6),
|
||||
audioPrice: audioPrice.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
textPrice: (textPrice * rate).toFixed(6),
|
||||
audioPrice: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1512,9 +1643,8 @@ export function renderAudioModelPrice(
|
||||
}
|
||||
|
||||
export function renderQuotaWithPrompt(quota, digits) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType !== 'TOKENS') {
|
||||
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
||||
}
|
||||
return '';
|
||||
@@ -1538,15 +1668,19 @@ export function renderClaudeModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -1575,28 +1709,31 @@ export function renderClaudeModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens',
|
||||
'补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: completionRatio,
|
||||
total: completionRatioPrice,
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheRatio,
|
||||
total: cacheRatioPrice,
|
||||
total: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1605,11 +1742,12 @@ export function renderClaudeModelPrice(
|
||||
{cacheCreationTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
'缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheCreationRatio,
|
||||
total: cacheCreationRatioPrice,
|
||||
total: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1619,33 +1757,35 @@ export function renderClaudeModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||
? i18next.t(
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: nonCachedTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cacheRatio: cacheRatio,
|
||||
cacheCreationInput: cacheCreationTokens,
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
cachePrice: cacheRatioPrice,
|
||||
cacheCreationPrice: cacheCreationRatioPrice,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1670,10 +1810,14 @@ export function renderClaudeLogContent(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
});
|
||||
|
||||
@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
|
||||
const numCompletion =
|
||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
|
||||
let symbol = '$';
|
||||
if (currency === 'CNY') {
|
||||
symbol = '¥';
|
||||
} else if (currency === 'CUSTOM') {
|
||||
try {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
} else {
|
||||
symbol = '¤';
|
||||
}
|
||||
} catch (e) {
|
||||
symbol = '¤';
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
|
||||
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||
unitLabel,
|
||||
isPerToken: true,
|
||||
usedGroup,
|
||||
|
||||
@@ -25,9 +25,13 @@ import {
|
||||
showInfo,
|
||||
showSuccess,
|
||||
loadChannelModels,
|
||||
copy
|
||||
copy,
|
||||
} from '../../helpers';
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
|
||||
import {
|
||||
CHANNEL_OPTIONS,
|
||||
ITEMS_PER_PAGE,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from '../../constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
@@ -64,7 +68,7 @@ export const useChannelsData = () => {
|
||||
|
||||
// Status filter
|
||||
const [statusFilter, setStatusFilter] = useState(
|
||||
localStorage.getItem('channel-status-filter') || 'all'
|
||||
localStorage.getItem('channel-status-filter') || 'all',
|
||||
);
|
||||
|
||||
// Type tabs states
|
||||
@@ -80,8 +84,8 @@ export const useChannelsData = () => {
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||
const shouldStopBatchTestingRef = useRef(false);
|
||||
|
||||
@@ -117,9 +121,12 @@ export const useChannelsData = () => {
|
||||
// Initialize from localStorage
|
||||
useEffect(() => {
|
||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode =
|
||||
localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete =
|
||||
localStorage.getItem('enable-batch-delete') === 'true';
|
||||
|
||||
setIdSort(localIdSort);
|
||||
setPageSize(localPageSize);
|
||||
@@ -177,7 +184,10 @@ export const useChannelsData = () => {
|
||||
// Save column preferences
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
|
||||
localStorage.setItem(
|
||||
'channels-table-columns',
|
||||
JSON.stringify(visibleColumns),
|
||||
);
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
@@ -291,14 +301,21 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||
setLoading(true);
|
||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = ++requestCounter.current;
|
||||
setLoading(true);
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||
@@ -312,7 +329,10 @@ export const useChannelsData = () => {
|
||||
if (success) {
|
||||
const { items, total, type_counts } = data;
|
||||
if (type_counts) {
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
}
|
||||
setChannelFormat(items, enableTagMode);
|
||||
@@ -336,11 +356,18 @@ export const useChannelsData = () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||
await loadChannels(
|
||||
page,
|
||||
pageSz,
|
||||
sortFlag,
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||
@@ -348,7 +375,10 @@ export const useChannelsData = () => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const { items = [], total = 0, type_counts = {} } = data;
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setChannelCount(total);
|
||||
@@ -367,7 +397,14 @@ export const useChannelsData = () => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(page, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -453,9 +490,16 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
setActivePage(page);
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -471,7 +515,14 @@ export const useChannelsData = () => {
|
||||
showError(reason);
|
||||
});
|
||||
} else {
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
1,
|
||||
size,
|
||||
idSort,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -502,7 +553,10 @@ export const useChannelsData = () => {
|
||||
showError(res?.data?.message || t('渠道复制失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
|
||||
showError(
|
||||
t('渠道复制失败: ') +
|
||||
(error?.response?.data?.message || error?.message || error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -541,7 +595,11 @@ export const useChannelsData = () => {
|
||||
data.priority = parseInt(data.priority);
|
||||
break;
|
||||
case 'weight':
|
||||
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
|
||||
if (
|
||||
data.weight === undefined ||
|
||||
data.weight < 0 ||
|
||||
data.weight === ''
|
||||
) {
|
||||
showInfo('权重必须是非负整数!');
|
||||
return;
|
||||
}
|
||||
@@ -684,7 +742,11 @@ export const useChannelsData = () => {
|
||||
const res = await API.post(`/api/channel/fix`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
|
||||
showSuccess(
|
||||
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
|
||||
.replace('${success}', data.success)
|
||||
.replace('${fails}', data.fails),
|
||||
);
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -701,7 +763,7 @@ export const useChannelsData = () => {
|
||||
}
|
||||
|
||||
// 添加到正在测试的模型集合
|
||||
setTestingModels(prev => new Set([...prev, model]));
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
|
||||
try {
|
||||
let url = `/api/channel/test/${record.id}?model=${model}`;
|
||||
@@ -718,14 +780,14 @@ export const useChannelsData = () => {
|
||||
const { success, message, time } = res.data;
|
||||
|
||||
// 更新测试结果
|
||||
setModelTestResults(prev => ({
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success,
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
if (success) {
|
||||
@@ -743,7 +805,9 @@ export const useChannelsData = () => {
|
||||
);
|
||||
} else {
|
||||
showInfo(
|
||||
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
|
||||
t(
|
||||
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
|
||||
)
|
||||
.replace('${name}', record.name)
|
||||
.replace('${model}', model)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
@@ -755,19 +819,19 @@ export const useChannelsData = () => {
|
||||
} catch (error) {
|
||||
// 处理网络错误
|
||||
const testKey = `${record.id}-${model}`;
|
||||
setModelTestResults(prev => ({
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success: false,
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
} finally {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels(prev => {
|
||||
setTestingModels((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(model);
|
||||
return newSet;
|
||||
@@ -782,9 +846,11 @@ export const useChannelsData = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const models = currentTestChannel.models.split(',').filter(model =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
);
|
||||
const models = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
showError(t('没有找到匹配的模型'));
|
||||
@@ -795,9 +861,9 @@ export const useChannelsData = () => {
|
||||
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||
|
||||
// 清空该渠道之前的测试结果
|
||||
setModelTestResults(prev => {
|
||||
setModelTestResults((prev) => {
|
||||
const newResults = { ...prev };
|
||||
models.forEach(model => {
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
delete newResults[testKey];
|
||||
});
|
||||
@@ -805,7 +871,12 @@ export const useChannelsData = () => {
|
||||
});
|
||||
|
||||
try {
|
||||
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
|
||||
showInfo(
|
||||
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
|
||||
'${count}',
|
||||
models.length,
|
||||
),
|
||||
);
|
||||
|
||||
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||
const concurrencyLimit = 5;
|
||||
@@ -819,13 +890,16 @@ export const useChannelsData = () => {
|
||||
}
|
||||
|
||||
const batch = models.slice(i, i + concurrencyLimit);
|
||||
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length)
|
||||
showInfo(
|
||||
t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
|
||||
const batchPromises = batch.map(model => testChannel(currentTestChannel, model, selectedEndpointType));
|
||||
const batchPromises = batch.map((model) =>
|
||||
testChannel(currentTestChannel, model, selectedEndpointType),
|
||||
);
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
@@ -837,20 +911,20 @@ export const useChannelsData = () => {
|
||||
|
||||
// 短暂延迟避免过于频繁的请求
|
||||
if (i + concurrencyLimit < models.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStopBatchTestingRef.current) {
|
||||
// 等待一小段时间确保所有结果都已更新
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 使用当前状态重新计算结果统计
|
||||
setModelTestResults(currentResults => {
|
||||
setModelTestResults((currentResults) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
models.forEach(model => {
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
const result = currentResults[testKey];
|
||||
if (result && result.success) {
|
||||
@@ -862,10 +936,11 @@ export const useChannelsData = () => {
|
||||
|
||||
// 显示完成消息
|
||||
setTimeout(() => {
|
||||
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length)
|
||||
showSuccess(
|
||||
t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
@@ -1053,4 +1128,4 @@ export const useChannelsData = () => {
|
||||
setCompactMode,
|
||||
setActivePage,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
|
||||
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
||||
[statusState, priceRate],
|
||||
);
|
||||
const customExchangeRate = useMemo(
|
||||
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
|
||||
[statusState],
|
||||
);
|
||||
const customCurrencySymbol = useMemo(
|
||||
() => statusState?.status?.custom_currency_symbol ?? '¤',
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
|
||||
const siteDisplayType = useMemo(
|
||||
() => statusState?.status?.quota_display_type || 'USD',
|
||||
[statusState],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
siteDisplayType === 'USD' ||
|
||||
siteDisplayType === 'CNY' ||
|
||||
siteDisplayType === 'CUSTOM'
|
||||
) {
|
||||
setCurrency(siteDisplayType);
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
|
||||
|
||||
if (currency === 'CNY') {
|
||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||
} else if (currency === 'CUSTOM') {
|
||||
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||
}
|
||||
return `$${priceInUSD.toFixed(3)}`;
|
||||
};
|
||||
|
||||
@@ -1810,7 +1810,10 @@
|
||||
"自定义模型名称": "Custom model name",
|
||||
"启用全部密钥": "Enable all keys",
|
||||
"充值价格显示": "Recharge price",
|
||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
|
||||
"自定义货币": "Custom currency",
|
||||
"自定义货币符号": "Custom currency symbol",
|
||||
"例如 €, £, Rp, ₩, ₹...": "For example, €, £, Rp, ₩, ₹...",
|
||||
"站点额度展示类型及汇率": "Site quota display type and exchange rate",
|
||||
"美元汇率": "USD exchange rate",
|
||||
"隐藏操作项": "Hide actions",
|
||||
"显示操作项": "Show actions",
|
||||
|
||||
@@ -1806,7 +1806,10 @@
|
||||
"自定义模型名称": "Nom de modèle personnalisé",
|
||||
"启用全部密钥": "Activer toutes les clés",
|
||||
"充值价格显示": "Prix de recharge",
|
||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "Taux de change USD (pas de taux de recharge, uniquement utilisé pour la conversion de la page de tarification)",
|
||||
"站点额度展示类型及汇率": "Type d'affichage du quota du site et taux de change",
|
||||
"自定义货币": "Devise personnalisée",
|
||||
"自定义货币符号": "Symbole de devise personnalisé",
|
||||
"例如 €, £, Rp, ₩, ₹...": "Par exemple, €, £, Rp, ₩, ₹...",
|
||||
"美元汇率": "Taux de change USD",
|
||||
"隐藏操作项": "Masquer les actions",
|
||||
"显示操作项": "Afficher les actions",
|
||||
@@ -2236,4 +2239,4 @@
|
||||
"重置 2FA": "Réinitialiser 2FA",
|
||||
"重置 Passkey": "Réinitialiser le Passkey",
|
||||
"默认使用系统名称": "Le nom du système est utilisé par défaut"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
InputGroup,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
|
||||
const [inputs, setInputs] = useState({
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': '',
|
||||
QuotaPerUnit: '',
|
||||
RetryTimes: '',
|
||||
USDExchangeRate: '',
|
||||
DisplayInCurrencyEnabled: false,
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
|
||||
});
|
||||
}
|
||||
|
||||
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
|
||||
const combinedRate = useMemo(() => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'USD') return '1';
|
||||
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
|
||||
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
|
||||
if (type === 'CUSTOM')
|
||||
return String(
|
||||
inputs['general_setting.custom_currency_exchange_rate'] || '',
|
||||
);
|
||||
return '';
|
||||
}, [inputs]);
|
||||
|
||||
const onCombinedRateChange = (val) => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'CNY') {
|
||||
handleFieldChange('USDExchangeRate')(val);
|
||||
} else if (type === 'TOKENS') {
|
||||
handleFieldChange('QuotaPerUnit')(val);
|
||||
} else if (type === 'CUSTOM') {
|
||||
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
// 若旧字段存在且新字段缺失,则做一次兜底映射
|
||||
if (
|
||||
currentInputs['general_setting.quota_display_type'] === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined
|
||||
) {
|
||||
currentInputs['general_setting.quota_display_type'] = props.options
|
||||
.DisplayInCurrencyEnabled
|
||||
? 'USD'
|
||||
: 'TOKENS';
|
||||
}
|
||||
// 回填自定义货币相关字段(如果后端已存在)
|
||||
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
|
||||
currentInputs['general_setting.custom_currency_symbol'] =
|
||||
props.options['general_setting.custom_currency_symbol'];
|
||||
}
|
||||
if (
|
||||
props.options['general_setting.custom_currency_exchange_rate'] !==
|
||||
undefined
|
||||
) {
|
||||
currentInputs['general_setting.custom_currency_exchange_rate'] =
|
||||
props.options['general_setting.custom_currency_exchange_rate'];
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
@@ -130,30 +189,7 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{inputs.QuotaPerUnit !== '500000' &&
|
||||
inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'USDExchangeRate'}
|
||||
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
|
||||
initValue={''}
|
||||
placeholder={t('美元汇率')}
|
||||
onChange={handleFieldChange('USDExchangeRate')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'RetryTimes'}
|
||||
@@ -164,18 +200,51 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayInCurrencyEnabled'}
|
||||
label={t('以货币形式显示额度')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DisplayInCurrencyEnabled')}
|
||||
<Form.Slot label={t('站点额度展示类型及汇率')}>
|
||||
<InputGroup style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={'1 USD = '}
|
||||
style={{ width: '50%' }}
|
||||
value={combinedRate}
|
||||
onChange={onCombinedRateChange}
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] === 'USD'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
value={inputs['general_setting.quota_display_type']}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
>
|
||||
<Select.Option value='USD'>USD ($)</Select.Option>
|
||||
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||
<Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'general_setting.custom_currency_symbol'}
|
||||
label={t('自定义货币符号')}
|
||||
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.custom_currency_symbol',
|
||||
)}
|
||||
showClear
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayTokenStatEnabled'}
|
||||
@@ -196,8 +265,6 @@ export default function GeneralSettings(props) {
|
||||
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DemoSiteEnabled'}
|
||||
|
||||
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui';
|
||||
import { Button, Col, Form, Row, Spin, DatePicker, Typography, Modal } from '@douyinfe/semi-ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsLog(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -78,24 +80,75 @@ export default function SettingsLog(props) {
|
||||
});
|
||||
}
|
||||
async function onCleanHistoryLog() {
|
||||
try {
|
||||
setLoadingCleanHistoryLog(true);
|
||||
if (!inputs.historyTimestamp) throw new Error(t('请选择日志记录时间'));
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} ${t('条日志已清理!')}`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(t('日志清理失败:') + message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoadingCleanHistoryLog(false);
|
||||
if (!inputs.historyTimestamp) {
|
||||
showError(t('请选择日志记录时间'));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const targetDate = dayjs(inputs.historyTimestamp);
|
||||
const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss');
|
||||
const currentTime = now.format('YYYY-MM-DD HH:mm:ss');
|
||||
const daysDiff = now.diff(targetDate, 'day');
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认清除历史日志'),
|
||||
content: (
|
||||
<div style={{ lineHeight: '1.8' }}>
|
||||
<p>
|
||||
<Text>{t('当前时间')}:</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>{currentTime}</Text>
|
||||
</p>
|
||||
<p>
|
||||
<Text>{t('选择时间')}:</Text>
|
||||
<Text strong type="danger">{targetTime}</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text type="tertiary"> ({t('约')} {daysDiff} {t('天前')})</Text>
|
||||
)}
|
||||
</p>
|
||||
<div style={{
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '12px'
|
||||
}}>
|
||||
<Text type="warning" strong>⚠️ {t('注意')}:</Text>
|
||||
<Text>{t('将删除')} </Text>
|
||||
<Text strong type="danger">{targetTime}</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text type="tertiary"> ({t('约')} {daysDiff} {t('天前')})</Text>
|
||||
)}
|
||||
<Text> {t('之前的所有日志')}</Text>
|
||||
</div>
|
||||
<p style={{ marginTop: '12px' }}>
|
||||
<Text type="danger">{t('此操作不可恢复,请仔细确认时间后再操作!')}</Text>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('确认删除'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoadingCleanHistoryLog(true);
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} ${t('条日志已清理!')}`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(t('日志清理失败:') + message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoadingCleanHistoryLog(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,7 +191,7 @@ export default function SettingsLog(props) {
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Spin spinning={loadingCleanHistoryLog}>
|
||||
<Form.DatePicker
|
||||
label={t('日志记录时间')}
|
||||
label={t('清除历史日志')}
|
||||
field={'historyTimestamp'}
|
||||
type='dateTime'
|
||||
inputReadOnly={true}
|
||||
@@ -149,7 +202,10 @@ export default function SettingsLog(props) {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button size='default' onClick={onCleanHistoryLog}>
|
||||
<Text type="tertiary" size="small" style={{ display: 'block', marginTop: 4, marginBottom: 8 }}>
|
||||
{t('将清除选定时间之前的所有日志')}
|
||||
</Text>
|
||||
<Button size='default' type='danger' onClick={onCleanHistoryLog}>
|
||||
{t('清除历史日志')}
|
||||
</Button>
|
||||
</Spin>
|
||||
|
||||
Reference in New Issue
Block a user