mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-31 16:54:03 +00:00
Compare commits
88 Commits
coderabbit
...
v0.9.6-pat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b099006c | ||
|
|
5fbf860020 | ||
|
|
eab768b4a0 | ||
|
|
1031f1ddf0 | ||
|
|
5f36e32821 | ||
|
|
11e8e4e7a6 | ||
|
|
35422b316d | ||
|
|
df0ae9294d | ||
|
|
57e5d67f86 | ||
|
|
7351480365 | ||
|
|
e19e904179 | ||
|
|
a54baf4998 | ||
|
|
721357b4a4 | ||
|
|
ff9f9fbbc9 | ||
|
|
9b551d978d | ||
|
|
76ab8a480a | ||
|
|
f091f663c2 | ||
|
|
e8966c7374 | ||
|
|
5a7f498629 | ||
|
|
4c1f138c0a | ||
|
|
f4d7bde20b | ||
|
|
0c181395b4 | ||
|
|
6897a9ffd8 | ||
|
|
77130dfb87 | ||
|
|
614abc3441 | ||
|
|
2479da4986 | ||
|
|
7b732ec4b7 | ||
|
|
0fed791ad9 | ||
|
|
7de02991a1 | ||
|
|
3c57cfbf71 | ||
|
|
fe9b305232 | ||
|
|
17dafa3b03 | ||
|
|
5f5b9425df | ||
|
|
b880094296 | ||
|
|
9c37b63f2e | ||
|
|
9f4a2d64a3 | ||
|
|
e24f13a277 | ||
|
|
d67c57eaa5 | ||
|
|
60dc910a27 | ||
|
|
629a534798 | ||
|
|
15a7edf6d6 | ||
|
|
cdd2eb517e | ||
|
|
1a398bbc40 | ||
|
|
581c51f312 | ||
|
|
8f00af181b | ||
|
|
0c417e8ec6 | ||
|
|
f930cdbb51 | ||
|
|
4d0a9d9494 | ||
|
|
6891057647 | ||
|
|
a610ef48e4 | ||
|
|
ddf5c85b81 | ||
|
|
ec590d1075 | ||
|
|
a8c9b24c7e | ||
|
|
2389dbafc5 | ||
|
|
6ef95c97cc | ||
|
|
2397ec8075 | ||
|
|
c24608730b | ||
|
|
ca9ee54fba | ||
|
|
bb0ed4dddf | ||
|
|
407da544fe | ||
|
|
98261ec9fa | ||
|
|
74f93d41f3 | ||
|
|
021892b17d | ||
|
|
9f44116260 | ||
|
|
8a56795bd8 | ||
|
|
1154077eea | ||
|
|
42861bc5fb | ||
|
|
7074ea2ed6 | ||
|
|
414be64d33 | ||
|
|
c1137027e6 | ||
|
|
ff77ba1157 | ||
|
|
3da7cebec6 | ||
|
|
7dc5f8c92d | ||
|
|
7763f11da7 | ||
|
|
8026e5142b | ||
|
|
9e33c83351 | ||
|
|
d2492d2af9 | ||
|
|
93e30703d4 | ||
|
|
b39885be1e | ||
|
|
15b21c075f | ||
|
|
922ecef31e | ||
|
|
573b5c3e3b | ||
|
|
7c7f9abd04 | ||
|
|
050e0221c7 | ||
|
|
a57a36a739 | ||
|
|
0046282fb8 | ||
|
|
723eefe9d8 | ||
|
|
2488e6ab66 |
@@ -5,4 +5,5 @@
|
||||
.gitignore
|
||||
Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.gocache
|
||||
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:
|
||||
|
||||
91
.github/workflows/sync-to-gitee.yml
vendored
Normal file
91
.github/workflows/sync-to-gitee.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Sync Release to Gitee
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Release Tag to sync (e.g. v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# 配置你的 Gitee 仓库信息
|
||||
env:
|
||||
GITEE_OWNER: 'QuantumNous' # 修改为你的 Gitee 用户名
|
||||
GITEE_REPO: 'new-api' # 修改为你的 Gitee 仓库名
|
||||
|
||||
jobs:
|
||||
sync-to-gitee:
|
||||
runs-on: sync
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get Release Info
|
||||
id: release_info
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ github.event.inputs.tag_name }}
|
||||
run: |
|
||||
# 获取 release 信息
|
||||
RELEASE_INFO=$(gh release view "$TAG_NAME" --json name,body,tagName,targetCommitish)
|
||||
|
||||
RELEASE_NAME=$(echo "$RELEASE_INFO" | jq -r '.name')
|
||||
TARGET_COMMITISH=$(echo "$RELEASE_INFO" | jq -r '.targetCommitish')
|
||||
|
||||
# 使用多行字符串输出
|
||||
{
|
||||
echo "release_name=$RELEASE_NAME"
|
||||
echo "target_commitish=$TARGET_COMMITISH"
|
||||
echo "release_body<<EOF"
|
||||
echo "$RELEASE_INFO" | jq -r '.body'
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
# 下载 release 的所有附件
|
||||
gh release download "$TAG_NAME" --dir ./release_assets || echo "No assets to download"
|
||||
|
||||
# 列出下载的文件
|
||||
ls -la ./release_assets/ || echo "No assets directory"
|
||||
|
||||
- name: Create Gitee Release
|
||||
id: create_release
|
||||
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
|
||||
with:
|
||||
gitee_action: create_release
|
||||
gitee_owner: ${{ env.GITEE_OWNER }}
|
||||
gitee_repo: ${{ env.GITEE_REPO }}
|
||||
gitee_token: ${{ secrets.GITEE_TOKEN }}
|
||||
gitee_tag_name: ${{ github.event.inputs.tag_name }}
|
||||
gitee_release_name: ${{ steps.release_info.outputs.release_name }}
|
||||
gitee_release_body: ${{ steps.release_info.outputs.release_body }}
|
||||
gitee_target_commitish: ${{ steps.release_info.outputs.target_commitish }}
|
||||
|
||||
- name: Upload Assets to Gitee
|
||||
if: hashFiles('release_assets/*') != ''
|
||||
uses: nICEnnnnnnnLee/action-gitee-release@v2.0.0
|
||||
with:
|
||||
gitee_action: upload_asset
|
||||
gitee_owner: ${{ env.GITEE_OWNER }}
|
||||
gitee_repo: ${{ env.GITEE_REPO }}
|
||||
gitee_token: ${{ secrets.GITEE_TOKEN }}
|
||||
gitee_release_id: ${{ steps.create_release.outputs.release-id }}
|
||||
gitee_upload_retry_times: 3
|
||||
gitee_files: |
|
||||
release_assets/*
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf release_assets/
|
||||
|
||||
- name: Summary
|
||||
if: success()
|
||||
run: |
|
||||
echo "✅ Successfully synced release ${{ github.event.inputs.tag_name }} to Gitee!"
|
||||
echo "🔗 Gitee Release URL: https://gitee.com/${{ env.GITEE_OWNER }}/${{ env.GITEE_REPO }}/releases/tag/${{ github.event.inputs.tag_name }}"
|
||||
|
||||
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:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,6 +9,11 @@ logs
|
||||
web/dist
|
||||
.env
|
||||
one-api
|
||||
new-api
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.gocache
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
30
README.en.md
30
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,10 +197,11 @@ 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)
|
||||
|
||||
|
||||
30
README.fr.md
30
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,10 +197,11 @@ 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"one-api/constant"
|
||||
"strings"
|
||||
@@ -113,3 +114,26 @@ func ApiSuccess(c *gin.Context, data any) {
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||
requestBody, err := GetRequestBody(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
boundary := ""
|
||||
if idx := strings.Index(contentType, "boundary="); idx != -1 {
|
||||
boundary = contentType[idx+9:]
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
|
||||
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset request body
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
return form, nil
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ const (
|
||||
ChannelTypeVidu = 52
|
||||
ChannelTypeSubmodel = 53
|
||||
ChannelTypeDoubaoVideo = 54
|
||||
ChannelTypeSora = 55
|
||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||
|
||||
)
|
||||
@@ -112,6 +113,7 @@ var ChannelBaseURLs = []string{
|
||||
"https://api.vidu.cn", //52
|
||||
"https://llm.submodel.ai", //53
|
||||
"https://ark.cn-beijing.volces.com", //54
|
||||
"https://api.openai.com", //55
|
||||
}
|
||||
|
||||
var ChannelTypeNames = map[int]string{
|
||||
@@ -166,6 +168,7 @@ var ChannelTypeNames = map[int]string{
|
||||
ChannelTypeVidu: "Vidu",
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
ChannelTypeSora: "Sora",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
|
||||
@@ -127,6 +127,14 @@ func GetAuthHeader(token string) http.Header {
|
||||
return h
|
||||
}
|
||||
|
||||
// GetClaudeAuthHeader get claude auth header
|
||||
func GetClaudeAuthHeader(token string) http.Header {
|
||||
h := http.Header{}
|
||||
h.Add("x-api-key", token)
|
||||
h.Add("anthropic-version", "2023-06-01")
|
||||
return h
|
||||
}
|
||||
|
||||
func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -59,6 +59,21 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
testModel = strings.TrimSpace(testModel)
|
||||
if testModel == "" {
|
||||
if channel.TestModel != nil && *channel.TestModel != "" {
|
||||
testModel = strings.TrimSpace(*channel.TestModel)
|
||||
} else {
|
||||
models := channel.GetModels()
|
||||
if len(models) > 0 {
|
||||
testModel = strings.TrimSpace(models[0])
|
||||
}
|
||||
if testModel == "" {
|
||||
testModel = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestPath := "/v1/chat/completions"
|
||||
|
||||
// 如果指定了端点类型,使用指定的端点类型
|
||||
@@ -90,18 +105,6 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if testModel == "" {
|
||||
if channel.TestModel != nil && *channel.TestModel != "" {
|
||||
testModel = *channel.TestModel
|
||||
} else {
|
||||
if len(channel.GetModels()) > 0 {
|
||||
testModel = channel.GetModels()[0]
|
||||
} else {
|
||||
testModel = "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache, err := model.GetUserCache(1)
|
||||
if err != nil {
|
||||
return testResult{
|
||||
@@ -619,10 +622,10 @@ func AutomaticallyTestChannels() {
|
||||
time.Sleep(10 * time.Minute)
|
||||
continue
|
||||
}
|
||||
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
|
||||
for {
|
||||
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
|
||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
|
||||
common.SysLog("automatically testing all channels")
|
||||
_ = testAllChannels(false)
|
||||
common.SysLog("automatically channel test finished")
|
||||
|
||||
@@ -198,9 +198,10 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
|
||||
var body []byte
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
if channel.Type == constant.ChannelTypeGemini {
|
||||
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it
|
||||
} else {
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeAnthropic:
|
||||
body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key))
|
||||
default:
|
||||
body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key))
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -43,6 +43,7 @@ func GetStatus(c *gin.Context) {
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
passkeySetting := system_setting.GetPasskeySettings()
|
||||
legalSetting := system_setting.GetLegalSettings()
|
||||
|
||||
data := gin.H{
|
||||
"version": common.Version,
|
||||
@@ -108,6 +109,8 @@ func GetStatus(c *gin.Context) {
|
||||
"passkey_user_verification": passkeySetting.UserVerification,
|
||||
"passkey_attachment": passkeySetting.AttachmentPreference,
|
||||
"setup": constant.Setup,
|
||||
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
||||
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
@@ -151,6 +154,24 @@ func GetAbout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetUserAgreement(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": system_setting.GetLegalSettings().UserAgreement,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetPrivacyPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": system_setting.GetLegalSettings().PrivacyPolicy,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetMidjourney(c *gin.Context) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
@@ -47,6 +47,11 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
|
||||
if adaptor == nil {
|
||||
return fmt.Errorf("video adaptor not found")
|
||||
}
|
||||
info := &relaycommon.RelayInfo{}
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||
}
|
||||
adaptor.Init(info)
|
||||
for _, taskId := range taskIds {
|
||||
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
||||
@@ -92,6 +97,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
taskResult.Url = t.FailReason
|
||||
taskResult.Progress = t.Progress
|
||||
taskResult.Reason = t.FailReason
|
||||
task.Data = t.Data
|
||||
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
|
||||
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||
} else {
|
||||
|
||||
@@ -183,12 +183,13 @@ func RequestEpay(c *gin.Context) {
|
||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||
}
|
||||
topUp := &model.TopUp{
|
||||
UserId: id,
|
||||
Amount: amount,
|
||||
Money: payMoney,
|
||||
TradeNo: tradeNo,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: "pending",
|
||||
UserId: id,
|
||||
Amount: amount,
|
||||
Money: payMoney,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: "pending",
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
@@ -236,8 +237,8 @@ func EpayNotify(c *gin.Context) {
|
||||
_, err := c.Writer.Write([]byte("fail"))
|
||||
if err != nil {
|
||||
log.Println("易支付回调写入失败")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err == nil && verifyInfo.VerifyStatus {
|
||||
@@ -313,3 +314,76 @@ func RequestAmount(c *gin.Context) {
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
}
|
||||
|
||||
func GetUserTopUps(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var (
|
||||
topups []*model.TopUp
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
if keyword != "" {
|
||||
topups, total, err = model.SearchUserTopUps(userId, keyword, pageInfo)
|
||||
} else {
|
||||
topups, total, err = model.GetUserTopUps(userId, pageInfo)
|
||||
}
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(topups)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetAllTopUps 管理员获取全平台充值记录
|
||||
func GetAllTopUps(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var (
|
||||
topups []*model.TopUp
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
if keyword != "" {
|
||||
topups, total, err = model.SearchAllTopUps(keyword, pageInfo)
|
||||
} else {
|
||||
topups, total, err = model.GetAllTopUps(pageInfo)
|
||||
}
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(topups)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
type AdminCompleteTopupRequest struct {
|
||||
TradeNo string `json:"trade_no"`
|
||||
}
|
||||
|
||||
// AdminCompleteTopUp 管理员补单接口
|
||||
func AdminCompleteTopUp(c *gin.Context) {
|
||||
var req AdminCompleteTopupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 订单级互斥,防止并发补单
|
||||
LockOrder(req.TradeNo)
|
||||
defer UnlockOrder(req.TradeNo)
|
||||
|
||||
if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -83,12 +83,13 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
}
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: id,
|
||||
Amount: req.Amount,
|
||||
Money: chargedMoney,
|
||||
TradeNo: referenceId,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
UserId: id,
|
||||
Amount: req.Amount,
|
||||
Money: chargedMoney,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
|
||||
129
controller/video_proxy.go
Normal file
129
controller/video_proxy.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/logger"
|
||||
"one-api/model"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func VideoProxy(c *gin.Context) {
|
||||
taskID := c.Param("task_id")
|
||||
if taskID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "task_id is required",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
task, exists, err := model.GetByOnlyTaskId(taskID)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to query task %s: %s", taskID, err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to query task",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if !exists || task == nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %s", taskID, err.Error()))
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Task not found",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if task.Status != model.TaskStatusSuccess {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Task is not completed yet, current status: %s", task.Status),
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := model.CacheGetChannel(task.ChannelId)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel %d: %s", task.ChannelId, err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to retrieve channel information",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
baseURL := channel.GetBaseURL()
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
videoURL := fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, videoURL, nil)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request for %s: %s", videoURL, err.Error()))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to create proxy request",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error()))
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "Failed to fetch video content",
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL))
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Upstream service returned status %d", resp.StatusCode),
|
||||
"type": "server_error",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 24 hours
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to stream video content: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -328,6 +329,7 @@ type GeminiImageParameters struct {
|
||||
SampleCount int `json:"sampleCount,omitempty"`
|
||||
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||
PersonGeneration string `json:"personGeneration,omitempty"`
|
||||
ImageSize string `json:"imageSize,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiImageResponse struct {
|
||||
|
||||
@@ -87,6 +87,12 @@ type GeneralOpenAIRequest struct {
|
||||
WebSearch json.RawMessage `json:"web_search,omitempty"`
|
||||
// doubao,zhipu_v4
|
||||
THINKING json.RawMessage `json:"thinking,omitempty"`
|
||||
// pplx Params
|
||||
SearchDomainFilter json.RawMessage `json:"search_domain_filter,omitempty"`
|
||||
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
|
||||
ReturnImages bool `json:"return_images,omitempty"`
|
||||
ReturnRelatedQuestions bool `json:"return_related_questions,omitempty"`
|
||||
SearchMode string `json:"search_mode,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -233,6 +233,16 @@ type Usage struct {
|
||||
Cost any `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIVideoResponse struct {
|
||||
Id string `json:"id" example:"file-abc123"`
|
||||
Object string `json:"object" example:"file"`
|
||||
Bytes int64 `json:"bytes" example:"120000"`
|
||||
CreatedAt int64 `json:"created_at" example:"1677610602"`
|
||||
ExpiresAt int64 `json:"expires_at" example:"1677614202"`
|
||||
Filename string `json:"filename" example:"mydata.jsonl"`
|
||||
Purpose string `json:"purpose" example:"fine-tune"`
|
||||
}
|
||||
|
||||
type InputTokenDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
CachedCreationTokens int `json:"-"`
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
4117
electron/package-lock.json
generated
Normal file
4117
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": "35.7.5",
|
||||
"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 |
3
go.mod
3
go.mod
@@ -21,7 +21,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
@@ -68,7 +68,6 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.25 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/gorilla/context v1.1.1 // indirect
|
||||
|
||||
@@ -165,6 +165,38 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
}
|
||||
c.Set("platform", string(constant.TaskPlatformSuno))
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/videos") {
|
||||
//curl https://api.openai.com/v1/videos \
|
||||
// -H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
// -F "model=sora-2" \
|
||||
// -F "prompt=A calico cat playing a piano on stage"
|
||||
// -F input_reference="@image.jpg"
|
||||
relayMode := relayconstant.RelayModeUnknown
|
||||
if c.Request.Method == http.MethodPost {
|
||||
relayMode = relayconstant.RelayModeVideoSubmit
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的video请求, " + err.Error())
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
if form != nil {
|
||||
if values, ok := form.Value["model"]; ok && len(values) > 0 {
|
||||
modelRequest.Model = values[0]
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(contentType, "application/json") {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
if err != nil {
|
||||
return nil, false, errors.New("无效的video请求, " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
shouldSelectChannel = false
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
||||
relayMode := relayconstant.RelayModeUnknown
|
||||
if c.Request.Method == http.MethodPost {
|
||||
|
||||
@@ -46,7 +46,7 @@ type Channel struct {
|
||||
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
||||
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
||||
HeaderOverride *string `json:"header_override" gorm:"type:text"`
|
||||
Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"`
|
||||
// add after v0.8.5
|
||||
ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
|
||||
|
||||
|
||||
221
model/topup.go
221
model/topup.go
@@ -6,18 +6,20 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/logger"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TopUp struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"`
|
||||
Amount int64 `json:"amount"`
|
||||
Money float64 `json:"money"`
|
||||
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
CompleteTime int64 `json:"complete_time"`
|
||||
Status string `json:"status"`
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"`
|
||||
Amount int64 `json:"amount"`
|
||||
Money float64 `json:"money"`
|
||||
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
CompleteTime int64 `json:"complete_time"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (topUp *TopUp) Insert() error {
|
||||
@@ -99,3 +101,206 @@ func Recharge(referenceId string, customerId string) (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
// Start transaction
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Get total count within transaction
|
||||
err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get paginated topups within same transaction
|
||||
err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// GetAllTopUps 获取全平台的充值记录(管理员使用)
|
||||
func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = tx.Model(&TopUp{}).Count(&total).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// SearchUserTopUps 按订单号搜索某用户的充值记录
|
||||
func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
query := tx.Model(&TopUp{}).Where("user_id = ?", userId)
|
||||
if keyword != "" {
|
||||
like := "%%" + keyword + "%%"
|
||||
query = query.Where("trade_no LIKE ?", like)
|
||||
}
|
||||
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用)
|
||||
func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
query := tx.Model(&TopUp{})
|
||||
if keyword != "" {
|
||||
like := "%%" + keyword + "%%"
|
||||
query = query.Where("trade_no LIKE ?", like)
|
||||
}
|
||||
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// ManualCompleteTopUp 管理员手动完成订单并给用户充值
|
||||
func ManualCompleteTopUp(tradeNo string) error {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供订单号")
|
||||
}
|
||||
|
||||
refCol := "`trade_no`"
|
||||
if common.UsingPostgreSQL {
|
||||
refCol = `"trade_no"`
|
||||
}
|
||||
|
||||
var userId int
|
||||
var quotaToAdd int
|
||||
var payMoney float64
|
||||
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
topUp := &TopUp{}
|
||||
// 行级锁,避免并发补单
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
// 幂等处理:已成功直接返回
|
||||
if topUp.Status == common.TopUpStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("订单状态不是待支付,无法补单")
|
||||
}
|
||||
|
||||
// 计算应充值额度:
|
||||
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
|
||||
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
|
||||
if topUp.PaymentMethod == "stripe" {
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
|
||||
} else {
|
||||
dAmount := decimal.NewFromInt(topUp.Amount)
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
|
||||
}
|
||||
if quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
}
|
||||
|
||||
// 标记完成
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
topUp.Status = common.TopUpStatusSuccess
|
||||
if err := tx.Save(topUp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 增加用户额度(立即写库,保持一致性)
|
||||
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userId = topUp.UserId
|
||||
payMoney = topUp.Money
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 事务外记录日志,避免阻塞
|
||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/types"
|
||||
|
||||
@@ -49,3 +50,7 @@ type TaskAdaptor interface {
|
||||
|
||||
ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
|
||||
}
|
||||
|
||||
type OpenAIVideoConverter interface {
|
||||
ConvertToOpenAIVideo(originTask *model.Task) (*relaycommon.OpenAIVideo, error)
|
||||
}
|
||||
|
||||
@@ -67,8 +67,12 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
aspectRatio = size
|
||||
} else {
|
||||
switch size {
|
||||
case "1024x1024":
|
||||
case "256x256", "512x512", "1024x1024":
|
||||
aspectRatio = "1:1"
|
||||
case "1536x1024":
|
||||
aspectRatio = "3:2"
|
||||
case "1024x1536":
|
||||
aspectRatio = "2:3"
|
||||
case "1024x1792":
|
||||
aspectRatio = "9:16"
|
||||
case "1792x1024":
|
||||
@@ -91,6 +95,28 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
},
|
||||
}
|
||||
|
||||
// Set imageSize when quality parameter is specified
|
||||
// Map quality parameter to imageSize (only supported by Standard and Ultra models)
|
||||
// quality values: auto, high, medium, low (for gpt-image-1), hd, standard (for dall-e-3)
|
||||
// imageSize values: 1K (default), 2K
|
||||
// https://ai.google.dev/gemini-api/docs/imagen
|
||||
// https://platform.openai.com/docs/api-reference/images/create
|
||||
if request.Quality != "" {
|
||||
imageSize := "1K" // default
|
||||
switch request.Quality {
|
||||
case "hd", "high":
|
||||
imageSize = "2K"
|
||||
case "2K":
|
||||
imageSize = "2K"
|
||||
case "standard", "medium", "low", "auto", "1K":
|
||||
imageSize = "1K"
|
||||
default:
|
||||
// unknown quality value, default to 1K
|
||||
imageSize = "1K"
|
||||
}
|
||||
geminiRequest.Parameters.ImageSize = imageSize
|
||||
}
|
||||
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -163,13 +163,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
if !containStreamUsage {
|
||||
usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
|
||||
usage.CompletionTokens += toolCount * 7
|
||||
} else {
|
||||
if info.ChannelType == constant.ChannelTypeDeepSeek {
|
||||
if usage.PromptCacheHitTokens != 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyUsagePostProcessing(info, usage, nil)
|
||||
|
||||
HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
|
||||
|
||||
return usage, nil
|
||||
@@ -233,6 +230,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
usageModified = true
|
||||
}
|
||||
|
||||
applyUsagePostProcessing(info, &simpleResponse.Usage, responseBody)
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatOpenAI:
|
||||
if usageModified {
|
||||
@@ -631,5 +630,60 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
||||
usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens
|
||||
usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens
|
||||
}
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
|
||||
if info == nil || usage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch info.ChannelType {
|
||||
case constant.ChannelTypeDeepSeek:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Usage struct {
|
||||
PromptTokensDetails struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Usage.PromptTokensDetails.CachedTokens != nil {
|
||||
return *payload.Usage.PromptTokensDetails.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.CachedTokens != nil {
|
||||
return *payload.Usage.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.PromptCacheHitTokens != nil {
|
||||
return *payload.Usage.PromptCacheHitTokens, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -22,10 +22,9 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
return nil, nil
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
@@ -80,11 +79,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
adaptor := openai.Adaptor{}
|
||||
usage, err = adaptor.DoResponse(c, resp, info)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package perplexity
|
||||
|
||||
var ModelList = []string{
|
||||
"llama-3-sonar-small-32k-chat", "llama-3-sonar-small-32k-online", "llama-3-sonar-large-32k-chat", "llama-3-sonar-large-32k-online", "llama-3-8b-instruct", "llama-3-70b-instruct", "mixtral-8x7b-instruct",
|
||||
"sonar", "sonar-pro", "sonar-reasoning",
|
||||
}
|
||||
|
||||
var ChannelName = "perplexity"
|
||||
|
||||
@@ -11,11 +11,18 @@ func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpen
|
||||
})
|
||||
}
|
||||
return &dto.GeneralOpenAIRequest{
|
||||
Model: request.Model,
|
||||
Stream: request.Stream,
|
||||
Messages: messages,
|
||||
Temperature: request.Temperature,
|
||||
TopP: request.TopP,
|
||||
MaxTokens: request.GetMaxTokens(),
|
||||
Model: request.Model,
|
||||
Stream: request.Stream,
|
||||
Messages: messages,
|
||||
Temperature: request.Temperature,
|
||||
TopP: request.TopP,
|
||||
MaxTokens: request.GetMaxTokens(),
|
||||
FrequencyPenalty: request.FrequencyPenalty,
|
||||
PresencePenalty: request.PresencePenalty,
|
||||
SearchDomainFilter: request.SearchDomainFilter,
|
||||
SearchRecencyFilter: request.SearchRecencyFilter,
|
||||
ReturnImages: request.ReturnImages,
|
||||
ReturnRelatedQuestions: request.ReturnRelatedQuestions,
|
||||
SearchMode: request.SearchMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/gopkg/util/logger"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"one-api/constant"
|
||||
@@ -303,14 +305,6 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
|
||||
return a.createJWTTokenWithKey(a.apiKey)
|
||||
}
|
||||
|
||||
//func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
||||
// parts := strings.Split(apiKey, "|")
|
||||
// if len(parts) != 2 {
|
||||
// return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
|
||||
// }
|
||||
// return a.createJWTTokenWithKey(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
|
||||
//}
|
||||
|
||||
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
||||
if isNewAPIRelay(apiKey) {
|
||||
return apiKey, nil // new api relay
|
||||
@@ -369,3 +363,50 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
||||
func isNewAPIRelay(apiKey string) bool {
|
||||
return strings.HasPrefix(apiKey, "sk-")
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) (*relaycommon.OpenAIVideo, error) {
|
||||
var klingResp responsePayload
|
||||
if err := json.Unmarshal(originTask.Data, &klingResp); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal kling task data failed")
|
||||
}
|
||||
|
||||
convertProgress := func(progress string) int {
|
||||
progress = strings.TrimSuffix(progress, "%")
|
||||
p, err := strconv.Atoi(progress)
|
||||
if err != nil {
|
||||
logger.Warnf("convert progress failed, progress: %s, err: %v", progress, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
openAIVideo := &relaycommon.OpenAIVideo{
|
||||
ID: klingResp.Data.TaskId,
|
||||
Object: "video",
|
||||
//Model: "kling-v1", //todo save model
|
||||
Status: string(originTask.Status),
|
||||
CreatedAt: klingResp.Data.CreatedAt,
|
||||
CompletedAt: klingResp.Data.UpdatedAt,
|
||||
Metadata: make(map[string]any),
|
||||
Progress: convertProgress(originTask.Progress),
|
||||
}
|
||||
|
||||
// 处理视频 URL
|
||||
if len(klingResp.Data.TaskResult.Videos) > 0 {
|
||||
video := klingResp.Data.TaskResult.Videos[0]
|
||||
if video.Url != "" {
|
||||
openAIVideo.Metadata["url"] = video.Url
|
||||
}
|
||||
if video.Duration != "" {
|
||||
openAIVideo.Seconds = video.Duration
|
||||
}
|
||||
}
|
||||
|
||||
if klingResp.Code != 0 && klingResp.Message != "" {
|
||||
openAIVideo.Error = &relaycommon.OpenAIVideoError{
|
||||
Message: klingResp.Message,
|
||||
Code: fmt.Sprintf("%d", klingResp.Code),
|
||||
}
|
||||
}
|
||||
|
||||
return openAIVideo, nil
|
||||
}
|
||||
|
||||
195
relay/channel/task/sora/adaptor.go
Normal file
195
relay/channel/task/sora/adaptor.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package sora
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/relay/channel"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/service"
|
||||
"one-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ============================
|
||||
// Request / Response structures
|
||||
// ============================
|
||||
|
||||
type ContentItem struct {
|
||||
Type string `json:"type"` // "text" or "image_url"
|
||||
Text string `json:"text,omitempty"` // for text type
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type responseTask struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id,omitempty"` //兼容旧接口
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
Seconds string `json:"seconds,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Adaptor implementation
|
||||
// ============================
|
||||
|
||||
type TaskAdaptor struct {
|
||||
ChannelType int
|
||||
apiKey string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
a.ChannelType = info.ChannelType
|
||||
a.baseURL = info.ChannelBaseUrl
|
||||
a.apiKey = info.ApiKey
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
|
||||
return relaycommon.ValidateMultipartDirect(c, info)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
return fmt.Sprintf("%s/v1/videos", a.baseURL), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
cachedBody, err := common.GetRequestBody(c)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get_request_body_failed")
|
||||
}
|
||||
return bytes.NewReader(cachedBody), nil
|
||||
}
|
||||
|
||||
// DoRequest delegates to common helper.
|
||||
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
return channel.DoTaskApiRequest(a, c, info, requestBody)
|
||||
}
|
||||
|
||||
// DoResponse handles upstream response, returns taskID etc.
|
||||
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Parse Sora response
|
||||
var dResp responseTask
|
||||
if err := json.Unmarshal(responseBody, &dResp); err != nil {
|
||||
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if dResp.ID == "" {
|
||||
if dResp.TaskID == "" {
|
||||
taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dResp.ID = dResp.TaskID
|
||||
dResp.TaskID = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dResp)
|
||||
return dResp.ID, responseBody, nil
|
||||
}
|
||||
|
||||
// FetchTask fetch task status
|
||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
|
||||
taskID, ok := body["task_id"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid task_id")
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s/v1/videos/%s", baseUrl, taskID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, uri, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) GetChannelName() string {
|
||||
return ChannelName
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) {
|
||||
resTask := responseTask{}
|
||||
if err := json.Unmarshal(respBody, &resTask); err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal task result failed")
|
||||
}
|
||||
|
||||
taskResult := relaycommon.TaskInfo{
|
||||
Code: 0,
|
||||
}
|
||||
|
||||
switch resTask.Status {
|
||||
case "queued", "pending":
|
||||
taskResult.Status = model.TaskStatusQueued
|
||||
case "processing", "in_progress":
|
||||
taskResult.Status = model.TaskStatusInProgress
|
||||
case "completed":
|
||||
taskResult.Status = model.TaskStatusSuccess
|
||||
taskResult.Url = fmt.Sprintf("%s/v1/videos/%s/content", system_setting.ServerAddress, resTask.ID)
|
||||
case "failed", "cancelled":
|
||||
taskResult.Status = model.TaskStatusFailure
|
||||
if resTask.Error != nil {
|
||||
taskResult.Reason = resTask.Error.Message
|
||||
} else {
|
||||
taskResult.Reason = "task failed"
|
||||
}
|
||||
default:
|
||||
}
|
||||
if resTask.Progress > 0 && resTask.Progress < 100 {
|
||||
taskResult.Progress = fmt.Sprintf("%d%%", resTask.Progress)
|
||||
}
|
||||
|
||||
return &taskResult, nil
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) (*relaycommon.OpenAIVideo, error) {
|
||||
openAIVideo := &relaycommon.OpenAIVideo{}
|
||||
err := json.Unmarshal(task.Data, openAIVideo)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unmarshal to OpenAIVideo failed")
|
||||
}
|
||||
return openAIVideo, nil
|
||||
}
|
||||
8
relay/channel/task/sora/constants.go
Normal file
8
relay/channel/task/sora/constants.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package sora
|
||||
|
||||
var ModelList = []string{
|
||||
"sora-2",
|
||||
"sora-2-pro",
|
||||
}
|
||||
|
||||
var ChannelName = "sora"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/gopkg/cache/asynccache"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// https://open.bigmodel.cn/doc/api#chatglm_std
|
||||
|
||||
@@ -261,6 +261,7 @@ var streamSupportedChannels = map[int]bool{
|
||||
constant.ChannelTypeXai: true,
|
||||
constant.ChannelTypeDeepSeek: true,
|
||||
constant.ChannelTypeBaiduV2: true,
|
||||
constant.ChannelTypeZhipu_v4: true,
|
||||
}
|
||||
|
||||
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
|
||||
@@ -549,3 +550,24 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
}
|
||||
return jsonDataAfter, nil
|
||||
}
|
||||
|
||||
type OpenAIVideo struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id,omitempty"` //兼容旧接口 待废弃
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
Seconds string `json:"seconds,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
RemixedFromVideoID string `json:"remixed_from_video_id,omitempty"`
|
||||
Error *OpenAIVideoError `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
type OpenAIVideoError struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type HasPrompt interface {
|
||||
@@ -52,7 +54,7 @@ func createTaskError(err error, code string, statusCode int, localError bool) *d
|
||||
}
|
||||
}
|
||||
|
||||
func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj interface{}) {
|
||||
func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj TaskSubmitReq) {
|
||||
info.Action = action
|
||||
c.Set("task_request", requestObj)
|
||||
}
|
||||
@@ -64,9 +66,167 @@ func validatePrompt(prompt string) *dto.TaskError {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
|
||||
func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string) (TaskSubmitReq, error) {
|
||||
var req TaskSubmitReq
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return req, err
|
||||
}
|
||||
|
||||
formData := c.Request.PostForm
|
||||
req = TaskSubmitReq{
|
||||
Prompt: formData.Get("prompt"),
|
||||
Model: formData.Get("model"),
|
||||
Mode: formData.Get("mode"),
|
||||
Image: formData.Get("image"),
|
||||
Size: formData.Get("size"),
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if durationStr := formData.Get("seconds"); durationStr != "" {
|
||||
if duration, err := strconv.Atoi(durationStr); err == nil {
|
||||
req.Duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
if images := formData["images"]; len(images) > 0 {
|
||||
req.Images = images
|
||||
}
|
||||
|
||||
for key, values := range formData {
|
||||
if len(values) > 0 && !isKnownTaskField(key) {
|
||||
if intVal, err := strconv.Atoi(values[0]); err == nil {
|
||||
req.Metadata[key] = intVal
|
||||
} else if floatVal, err := strconv.ParseFloat(values[0], 64); err == nil {
|
||||
req.Metadata[key] = floatVal
|
||||
} else {
|
||||
req.Metadata[key] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
var prompt string
|
||||
var model string
|
||||
var seconds int
|
||||
var size string
|
||||
var hasInputReference bool
|
||||
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
|
||||
prompts, ok := form.Value["prompt"]
|
||||
if !ok || len(prompts) == 0 {
|
||||
return createTaskError(fmt.Errorf("prompt field is required"), "missing_prompt", http.StatusBadRequest, true)
|
||||
}
|
||||
prompt = prompts[0]
|
||||
|
||||
if _, ok := form.Value["model"]; !ok {
|
||||
return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true)
|
||||
}
|
||||
model = form.Value["model"][0]
|
||||
|
||||
if _, ok := form.File["input_reference"]; ok {
|
||||
hasInputReference = true
|
||||
}
|
||||
|
||||
if ss, ok := form.Value["seconds"]; ok {
|
||||
sInt := common.String2Int(ss[0])
|
||||
if sInt > seconds {
|
||||
seconds = common.String2Int(ss[0])
|
||||
}
|
||||
}
|
||||
|
||||
if sz, ok := form.Value["size"]; ok {
|
||||
size = sz[0]
|
||||
}
|
||||
} else {
|
||||
var req TaskSubmitReq
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_json", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
prompt = req.Prompt
|
||||
model = req.Model
|
||||
seconds = req.Duration
|
||||
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
if req.HasImage() {
|
||||
hasInputReference = true
|
||||
}
|
||||
}
|
||||
|
||||
if taskErr := validatePrompt(prompt); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
action := constant.TaskActionTextGenerate
|
||||
if hasInputReference {
|
||||
action = constant.TaskActionGenerate
|
||||
}
|
||||
if strings.HasPrefix(model, "sora-2") {
|
||||
|
||||
if size == "" {
|
||||
size = "720x1280"
|
||||
}
|
||||
|
||||
if seconds <= 0 {
|
||||
seconds = 4
|
||||
}
|
||||
|
||||
if model == "sora-2" && !lo.Contains([]string{"720x1280", "1280x720"}, size) {
|
||||
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
|
||||
}
|
||||
if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792"}, size) {
|
||||
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
|
||||
}
|
||||
info.PriceData.OtherRatios = map[string]float64{
|
||||
"seconds": float64(seconds),
|
||||
"size": 1,
|
||||
}
|
||||
if lo.Contains([]string{"1792x1024", "1024x1792"}, size) {
|
||||
info.PriceData.OtherRatios["size"] = 1.666667
|
||||
}
|
||||
}
|
||||
|
||||
info.Action = action
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKnownTaskField(field string) bool {
|
||||
knownFields := map[string]bool{
|
||||
"prompt": true,
|
||||
"model": true,
|
||||
"mode": true,
|
||||
"image": true,
|
||||
"images": true,
|
||||
"size": true,
|
||||
"duration": true,
|
||||
"input_reference": true, // Sora 特有字段
|
||||
}
|
||||
return knownFields[field]
|
||||
}
|
||||
|
||||
func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
|
||||
var err error
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
var req TaskSubmitReq
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
req, err = validateMultipartTaskRequest(c, info, action)
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
|
||||
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
|
||||
// 如果没有配置价格,则使用默认价格
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[info.OriginModelName]
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
taskdoubao "one-api/relay/channel/task/doubao"
|
||||
taskjimeng "one-api/relay/channel/task/jimeng"
|
||||
"one-api/relay/channel/task/kling"
|
||||
tasksora "one-api/relay/channel/task/sora"
|
||||
"one-api/relay/channel/task/suno"
|
||||
taskvertex "one-api/relay/channel/task/vertex"
|
||||
taskVidu "one-api/relay/channel/task/vidu"
|
||||
@@ -137,6 +138,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor {
|
||||
return &taskVidu.TaskAdaptor{}
|
||||
case constant.ChannelTypeDoubaoVideo:
|
||||
return &taskdoubao.TaskAdaptor{}
|
||||
case constant.ChannelTypeSora:
|
||||
return &tasksora.TaskAdaptor{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/relay/channel"
|
||||
relaycommon "one-api/relay/common"
|
||||
relayconstant "one-api/relay/constant"
|
||||
"one-api/service"
|
||||
@@ -53,7 +54,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
|
||||
}
|
||||
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[modelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
} else {
|
||||
@@ -70,6 +71,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
|
||||
} else {
|
||||
ratio = modelPrice * groupRatio
|
||||
}
|
||||
if len(info.PriceData.OtherRatios) > 0 {
|
||||
for _, ra := range info.PriceData.OtherRatios {
|
||||
if 1.0 != ra {
|
||||
ratio *= ra
|
||||
}
|
||||
}
|
||||
}
|
||||
println(fmt.Sprintf("model: %s, model_price: %.4f, group: %s, group_ratio: %.4f, final_ratio: %.4f", modelName, modelPrice, info.UsingGroup, groupRatio, ratio))
|
||||
userQuota, err := model.GetUserQuota(info.UserId, false)
|
||||
if err != nil {
|
||||
taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
|
||||
@@ -138,11 +147,22 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
|
||||
}
|
||||
if quota != 0 {
|
||||
tokenName := c.GetString("token_name")
|
||||
gRatio := groupRatio
|
||||
if hasUserGroupRatio {
|
||||
gRatio = userGroupRatio
|
||||
//gRatio := groupRatio
|
||||
//if hasUserGroupRatio {
|
||||
// gRatio = userGroupRatio
|
||||
//}
|
||||
logContent := fmt.Sprintf("操作 %s", info.Action)
|
||||
if len(info.PriceData.OtherRatios) > 0 {
|
||||
var contents []string
|
||||
for key, ra := range info.PriceData.OtherRatios {
|
||||
if 1.0 != ra {
|
||||
contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
|
||||
}
|
||||
}
|
||||
if len(contents) > 0 {
|
||||
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
|
||||
}
|
||||
}
|
||||
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, info.Action)
|
||||
other := make(map[string]interface{})
|
||||
other["model_price"] = modelPrice
|
||||
other["group_ratio"] = groupRatio
|
||||
@@ -362,11 +382,34 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d
|
||||
}
|
||||
}()
|
||||
|
||||
if len(respBody) == 0 {
|
||||
respBody, err = json.Marshal(dto.TaskResponse[any]{
|
||||
Code: "success",
|
||||
Data: TaskModel2Dto(originTask),
|
||||
})
|
||||
if len(respBody) != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.Request.RequestURI, "/v1/videos/") {
|
||||
adaptor := GetTaskAdaptor(originTask.Platform)
|
||||
if adaptor == nil {
|
||||
taskResp = service.TaskErrorWrapperLocal(fmt.Errorf("invalid channel id: %d", originTask.ChannelId), "invalid_channel_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if converter, ok := adaptor.(channel.OpenAIVideoConverter); ok {
|
||||
openAIVideo, err := converter.ConvertToOpenAIVideo(originTask)
|
||||
if err != nil {
|
||||
taskResp = service.TaskErrorWrapper(err, "convert_to_openai_video_failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
respBody, _ = json.Marshal(openAIVideo)
|
||||
return
|
||||
}
|
||||
taskResp = service.TaskErrorWrapperLocal(errors.New(fmt.Sprintf("not_implemented:%s", originTask.Platform)), "not_implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
respBody, err = json.Marshal(dto.TaskResponse[any]{
|
||||
Code: "success",
|
||||
Data: TaskModel2Dto(originTask),
|
||||
})
|
||||
if err != nil {
|
||||
taskResp = service.TaskErrorWrapper(err, "marshal_response_failed", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
apiRouter.GET("/user-agreement", controller.GetUserAgreement)
|
||||
apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
@@ -73,6 +75,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.DELETE("/passkey", controller.PasskeyDelete)
|
||||
selfRoute.GET("/aff", controller.GetAffCode)
|
||||
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
||||
selfRoute.GET("/topup/self", controller.GetUserTopUps)
|
||||
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
||||
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
|
||||
selfRoute.POST("/amount", controller.RequestAmount)
|
||||
@@ -93,6 +96,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
adminRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
adminRoute.GET("/", controller.GetAllUsers)
|
||||
adminRoute.GET("/topup", controller.GetAllTopUps)
|
||||
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
adminRoute.GET("/:id", controller.GetUser)
|
||||
adminRoute.POST("/", controller.CreateUser)
|
||||
|
||||
@@ -9,11 +9,18 @@ import (
|
||||
|
||||
func SetVideoRouter(router *gin.Engine) {
|
||||
videoV1Router := router.Group("/v1")
|
||||
videoV1Router.GET("/videos/:task_id/content", controller.VideoProxy)
|
||||
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
videoV1Router.POST("/video/generations", controller.RelayTask)
|
||||
videoV1Router.GET("/video/generations/:task_id", controller.RelayTask)
|
||||
}
|
||||
// openai compatible API video routes
|
||||
// docs: https://platform.openai.com/docs/api-reference/videos/create
|
||||
{
|
||||
videoV1Router.POST("/videos", controller.RelayTask)
|
||||
videoV1Router.GET("/videos/:task_id", controller.RelayTask)
|
||||
}
|
||||
|
||||
klingV1Router := router.Group("/kling/v1")
|
||||
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
|
||||
@@ -75,6 +75,8 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
return true
|
||||
case "pre_consume_token_quota_failed":
|
||||
return true
|
||||
case "Arrearage":
|
||||
return true
|
||||
}
|
||||
switch oaiErr.Type {
|
||||
case "insufficient_quota":
|
||||
|
||||
@@ -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 信息
|
||||
|
||||
@@ -45,7 +45,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("failed to marshal worker payload: %v", err)
|
||||
}
|
||||
|
||||
return http.Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
|
||||
return GetHttpClient().Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload))
|
||||
}
|
||||
|
||||
func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) {
|
||||
@@ -64,6 +64,6 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
|
||||
}
|
||||
|
||||
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
|
||||
return http.Get(originUrl)
|
||||
return GetHttpClient().Get(originUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"one-api/common"
|
||||
"one-api/setting/system_setting"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,12 +20,27 @@ var (
|
||||
proxyClients = make(map[string]*http.Client)
|
||||
)
|
||||
|
||||
func checkRedirect(req *http.Request, via []*http.Request) error {
|
||||
fetchSetting := system_setting.GetFetchSetting()
|
||||
urlStr := req.URL.String()
|
||||
if err := common.ValidateURLWithFetchSetting(urlStr, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||
return fmt.Errorf("redirect to %s blocked: %v", urlStr, err)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitHttpClient() {
|
||||
if common.RelayTimeout == 0 {
|
||||
httpClient = &http.Client{}
|
||||
httpClient = &http.Client{
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
} else {
|
||||
httpClient = &http.Client{
|
||||
Timeout: time.Duration(common.RelayTimeout) * time.Second,
|
||||
Timeout: time.Duration(common.RelayTimeout) * time.Second,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +85,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsedURL),
|
||||
},
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
@@ -102,6 +119,7 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
},
|
||||
},
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||
proxyClientLock.Lock()
|
||||
|
||||
@@ -290,6 +290,8 @@ var defaultModelPrice = map[string]float64{
|
||||
"mj_upscale": 0.05,
|
||||
"swap_face": 0.05,
|
||||
"mj_upload": 0.05,
|
||||
"sora-2": 0.3,
|
||||
"sora-2-pro": 0.5,
|
||||
}
|
||||
|
||||
var defaultAudioRatio = map[string]float64{
|
||||
@@ -452,6 +454,10 @@ func GetDefaultModelRatioMap() map[string]float64 {
|
||||
return defaultModelRatio
|
||||
}
|
||||
|
||||
func GetDefaultModelPriceMap() map[string]float64 {
|
||||
return defaultModelPrice
|
||||
}
|
||||
|
||||
func GetDefaultImageRatioMap() map[string]float64 {
|
||||
return defaultImageRatio
|
||||
}
|
||||
|
||||
21
setting/system_setting/legal.go
Normal file
21
setting/system_setting/legal.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package system_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
type LegalSettings struct {
|
||||
UserAgreement string `json:"user_agreement"`
|
||||
PrivacyPolicy string `json:"privacy_policy"`
|
||||
}
|
||||
|
||||
var defaultLegalSettings = LegalSettings{
|
||||
UserAgreement: "",
|
||||
PrivacyPolicy: "",
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("legal", &defaultLegalSettings)
|
||||
}
|
||||
|
||||
func GetLegalSettings() *LegalSettings {
|
||||
return &defaultLegalSettings
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -17,6 +17,7 @@ type PriceData struct {
|
||||
ImageRatio float64
|
||||
AudioRatio float64
|
||||
AudioCompletionRatio float64
|
||||
OtherRatios map[string]float64
|
||||
UsePrice bool
|
||||
ShouldPreConsumedQuota int
|
||||
GroupRatioInfo GroupRatioInfo
|
||||
|
||||
38
web/bun.lock
38
web/bun.lock
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "^0.27.2",
|
||||
"axios": "1.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"dayjs": "^1.11.11",
|
||||
@@ -687,7 +687,7 @@
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
|
||||
|
||||
"axios": ["axios@0.27.2", "", { "dependencies": { "follow-redirects": "^1.14.9", "form-data": "^4.0.0" } }, "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ=="],
|
||||
"axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
@@ -713,6 +713,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
@@ -895,6 +897,8 @@
|
||||
|
||||
"dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.157", "", {}, "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w=="],
|
||||
@@ -907,6 +911,14 @@
|
||||
|
||||
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
|
||||
|
||||
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
||||
@@ -995,7 +1007,7 @@
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="],
|
||||
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
|
||||
|
||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||
|
||||
@@ -1019,6 +1031,10 @@
|
||||
|
||||
"geojson-linestring-dissolve": ["geojson-linestring-dissolve@0.0.1", "", {}, "sha512-Y8I2/Ea28R/Xeki7msBcpMvJL2TaPfaPKP8xqueJfQ9/jEhps+iOJxOR2XCBGgVb12Z6XnDb1CMbaPfLepsLaw=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stdin": ["get-stdin@6.0.0", "", {}, "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g=="],
|
||||
|
||||
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
|
||||
@@ -1031,6 +1047,8 @@
|
||||
|
||||
"globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
@@ -1039,6 +1057,10 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
||||
@@ -1229,6 +1251,8 @@
|
||||
|
||||
"marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
@@ -1491,6 +1515,8 @@
|
||||
|
||||
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
@@ -1505,7 +1531,7 @@
|
||||
|
||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||
|
||||
"rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||
|
||||
@@ -1949,6 +1975,8 @@
|
||||
|
||||
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
||||
|
||||
"@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
||||
@@ -1965,8 +1993,6 @@
|
||||
|
||||
"@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="],
|
||||
|
||||
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "^0.27.2",
|
||||
"axios": "1.12.0",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"dayjs": "^1.11.11",
|
||||
|
||||
@@ -51,6 +51,8 @@ import SetupCheck from './components/layout/SetupCheck';
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
@@ -301,6 +303,22 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user-agreement'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<UserAgreement />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/privacy-policy'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<PrivacyPolicy />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/chat/:id?'
|
||||
element={
|
||||
|
||||
@@ -37,12 +37,17 @@ import {
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
@@ -79,6 +84,9 @@ const LoginForm = () => {
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -98,6 +106,10 @@ const LoginForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -113,6 +125,10 @@ const LoginForm = () => {
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
@@ -152,6 +168,10 @@ const LoginForm = () => {
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
@@ -203,6 +223,10 @@ const LoginForm = () => {
|
||||
|
||||
// 添加Telegram登录处理函数
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
@@ -239,6 +263,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的GitHub登录点击处理
|
||||
const handleGitHubClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setGithubLoading(true);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id);
|
||||
@@ -250,6 +278,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的OIDC登录点击处理
|
||||
const handleOIDCClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
@@ -261,6 +293,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的LinuxDO登录点击处理
|
||||
const handleLinuxDOClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id);
|
||||
@@ -278,6 +314,10 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
showInfo('当前环境无法使用 Passkey 登录');
|
||||
return;
|
||||
@@ -296,15 +336,22 @@ const LoginForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
|
||||
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||
const publicKeyOptions = prepareCredentialRequestOptions(
|
||||
data?.options || data?.publicKey || data,
|
||||
);
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
const payload = buildAssertionResult(assertion);
|
||||
if (!payload) {
|
||||
showError('Passkey 验证失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post('/api/user/passkey/login/finish', payload);
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/login/finish',
|
||||
payload,
|
||||
);
|
||||
const finish = finishRes.data;
|
||||
if (finish.success) {
|
||||
userDispatch({ type: 'login', payload: finish.data });
|
||||
@@ -474,6 +521,44 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='mt-6'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
@@ -542,6 +627,44 @@ const LoginForm = () => {
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -550,6 +673,7 @@ const LoginForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import {
|
||||
@@ -82,6 +82,9 @@ const RegisterForm = () => {
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -106,6 +109,10 @@ const RegisterForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -505,6 +512,44 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -513,6 +558,7 @@ const RegisterForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('注册')}
|
||||
</Button>
|
||||
|
||||
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
const { Title } = Typography;
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
// 检查是否为 URL
|
||||
const isUrl = (content) => {
|
||||
try {
|
||||
new URL(content.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否为 HTML 内容
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
};
|
||||
|
||||
// 安全地渲染HTML内容
|
||||
const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map(style => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
return { content, styles };
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用文档渲染组件
|
||||
* @param {string} apiEndpoint - API 接口地址
|
||||
* @param {string} title - 文档标题
|
||||
* @param {string} cacheKey - 本地存储缓存键
|
||||
* @param {string} emptyMessage - 空内容时的提示消息
|
||||
*/
|
||||
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [htmlStyles, setHtmlStyles] = useState('');
|
||||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||||
|
||||
const loadContent = async () => {
|
||||
// 先从缓存中获取
|
||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||||
if (cachedContent) {
|
||||
setContent(cachedContent);
|
||||
processContent(cachedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await API.get(apiEndpoint);
|
||||
const { success, message, data } = res.data;
|
||||
if (success && data) {
|
||||
setContent(data);
|
||||
processContent(data);
|
||||
localStorage.setItem(cacheKey, data);
|
||||
} else {
|
||||
if (!cachedContent) {
|
||||
showError(message || emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cachedContent) {
|
||||
showError(emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (rawContent) => {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||||
setProcessedHtmlContent(htmlContent);
|
||||
setHtmlStyles(styles);
|
||||
} else {
|
||||
setProcessedHtmlContent('');
|
||||
setHtmlStyles('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
}, []);
|
||||
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
|
||||
if (htmlStyles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = styleId;
|
||||
styleEl.type = 'text/css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.innerHTML = htmlStyles;
|
||||
} else {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
};
|
||||
}, [htmlStyles, cacheKey]);
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
className='p-8'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>{title}</Title>
|
||||
<p className='text-gray-600 mb-4'>
|
||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||
</p>
|
||||
<a
|
||||
href={content.trim()}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
title={content.trim()}
|
||||
aria-label={`${t('访问' + title)}: ${content.trim()}`}
|
||||
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
|
||||
>
|
||||
{t('访问' + title)}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div className='prose prose-lg max-w-none'>
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
@@ -58,7 +58,7 @@ const ChannelKeyViewExample = ({ channelId }) => {
|
||||
// 开始查看密钥流程
|
||||
const handleViewKey = async () => {
|
||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||
|
||||
|
||||
await startVerification(apiCall, {
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
@@ -69,11 +69,7 @@ const ChannelKeyViewExample = ({ channelId }) => {
|
||||
return (
|
||||
<>
|
||||
{/* 查看密钥按钮 */}
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleViewKey}
|
||||
>
|
||||
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
|
||||
@@ -114,4 +110,4 @@ const ChannelKeyViewExample = ({ channelId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyViewExample;
|
||||
export default ChannelKeyViewExample;
|
||||
|
||||
@@ -19,7 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Input,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
|
||||
/**
|
||||
* 通用安全验证模态框组件
|
||||
@@ -78,9 +87,7 @@ const SecureVerificationModal = ({
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<Button onClick={onCancel}>{t('确定')}</Button>
|
||||
}
|
||||
footer={<Button onClick={onCancel}>{t('确定')}</Button>}
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
@@ -123,21 +130,21 @@ const SecureVerificationModal = ({
|
||||
width={460}
|
||||
centered
|
||||
style={{
|
||||
maxWidth: 'calc(100vw - 32px)'
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '20px 24px'
|
||||
padding: '20px 24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
type="tertiary"
|
||||
type='tertiary'
|
||||
style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
@@ -153,10 +160,7 @@ const SecureVerificationModal = ({
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{has2FA && (
|
||||
<TabPane
|
||||
tab={t('两步验证')}
|
||||
itemKey='2fa'
|
||||
>
|
||||
<TabPane tab={t('两步验证')} itemKey='2fa'>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Input
|
||||
@@ -169,8 +173,21 @@ const SecureVerificationModal = ({
|
||||
autoFocus={method === '2fa'}
|
||||
disabled={loading}
|
||||
prefix={
|
||||
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
<svg
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
@@ -178,24 +195,26 @@ const SecureVerificationModal = ({
|
||||
</div>
|
||||
|
||||
<Typography.Text
|
||||
type="tertiary"
|
||||
size="small"
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '20px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{t('从认证器应用中获取验证码,或使用备用码')}
|
||||
</Typography.Text>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
@@ -214,31 +233,47 @@ const SecureVerificationModal = ({
|
||||
)}
|
||||
|
||||
{hasPasskey && passkeySupported && (
|
||||
<TabPane
|
||||
tab={t('Passkey')}
|
||||
itemKey='passkey'
|
||||
>
|
||||
<TabPane tab={t('Passkey')} itemKey='passkey'>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
}}>
|
||||
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
|
||||
<path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
color: 'var(--semi-color-primary)',
|
||||
}}
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
|
||||
<Typography.Title
|
||||
heading={5}
|
||||
style={{ margin: '0 0 8px', fontSize: '16px' }}
|
||||
>
|
||||
{t('使用 Passkey 验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text
|
||||
@@ -247,19 +282,21 @@ const SecureVerificationModal = ({
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{t('点击验证按钮,使用您的生物特征或安全密钥')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
@@ -282,4 +319,4 @@ const SecureVerificationModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureVerificationModal;
|
||||
export default SecureVerificationModal;
|
||||
|
||||
@@ -34,10 +34,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const LEGAL_USER_AGREEMENT_KEY = 'legal.user_agreement';
|
||||
const LEGAL_PRIVACY_POLICY_KEY = 'legal.privacy_policy';
|
||||
|
||||
const OtherSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
Notice: '',
|
||||
[LEGAL_USER_AGREEMENT_KEY]: '',
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: '',
|
||||
SystemName: '',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
@@ -69,6 +74,8 @@ const OtherSetting = () => {
|
||||
|
||||
const [loadingInput, setLoadingInput] = useState({
|
||||
Notice: false,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
SystemName: false,
|
||||
Logo: false,
|
||||
HomePageContent: false,
|
||||
@@ -96,6 +103,50 @@ const OtherSetting = () => {
|
||||
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
|
||||
}
|
||||
};
|
||||
// 通用设置 - UserAgreement
|
||||
const submitUserAgreement = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_USER_AGREEMENT_KEY,
|
||||
inputs[LEGAL_USER_AGREEMENT_KEY],
|
||||
);
|
||||
showSuccess(t('用户协议已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('用户协议更新失败'), error);
|
||||
showError(t('用户协议更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 通用设置 - PrivacyPolicy
|
||||
const submitPrivacyPolicy = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_PRIVACY_POLICY_KEY,
|
||||
inputs[LEGAL_PRIVACY_POLICY_KEY],
|
||||
);
|
||||
showSuccess(t('隐私政策已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('隐私政策更新失败'), error);
|
||||
showError(t('隐私政策更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 个性化设置
|
||||
const formAPIPersonalization = useRef();
|
||||
// 个性化设置 - SystemName
|
||||
@@ -324,6 +375,40 @@ const OtherSetting = () => {
|
||||
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
|
||||
{t('设置公告')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('用户协议')}
|
||||
placeholder={t(
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_USER_AGREEMENT_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitUserAgreement}
|
||||
loading={loadingInput[LEGAL_USER_AGREEMENT_KEY]}
|
||||
>
|
||||
{t('设置用户协议')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('隐私政策')}
|
||||
placeholder={t(
|
||||
'在此输入隐私政策内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_PRIVACY_POLICY_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitPrivacyPolicy}
|
||||
loading={loadingInput[LEGAL_PRIVACY_POLICY_KEY]}
|
||||
>
|
||||
{t('设置隐私政策')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
</Form>
|
||||
|
||||
@@ -155,9 +155,7 @@ const PersonalSetting = () => {
|
||||
gotifyUrl: settings.gotify_url || '',
|
||||
gotifyToken: settings.gotify_token || '',
|
||||
gotifyPriority:
|
||||
settings.gotify_priority !== undefined
|
||||
? settings.gotify_priority
|
||||
: 5,
|
||||
settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
@@ -214,7 +212,9 @@ const PersonalSetting = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
|
||||
const publicKey = prepareCredentialCreationOptions(
|
||||
data?.options || data?.publicKey || data,
|
||||
);
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const payload = buildRegistrationResult(credential);
|
||||
if (!payload) {
|
||||
@@ -222,7 +222,10 @@ const PersonalSetting = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/register/finish',
|
||||
payload,
|
||||
);
|
||||
if (finishRes.data.success) {
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
|
||||
@@ -615,7 +615,10 @@ const SystemSetting = () => {
|
||||
|
||||
options.push({
|
||||
key: 'passkey.rp_display_name',
|
||||
value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
|
||||
value:
|
||||
formValues['passkey.rp_display_name'] ||
|
||||
inputs['passkey.rp_display_name'] ||
|
||||
'',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.rp_id',
|
||||
@@ -623,11 +626,17 @@ const SystemSetting = () => {
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.user_verification',
|
||||
value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
|
||||
value:
|
||||
formValues['passkey.user_verification'] ||
|
||||
inputs['passkey.user_verification'] ||
|
||||
'preferred',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.attachment_preference',
|
||||
value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
|
||||
value:
|
||||
formValues['passkey.attachment_preference'] ||
|
||||
inputs['passkey.attachment_preference'] ||
|
||||
'',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.origins',
|
||||
@@ -696,8 +705,15 @@ const SystemSetting = () => {
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('代理设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'此代理仅用于图片请求转发,Webhook通知发送等,AI API请求仍然由服务器直接发出,可在渠道设置中单独配置代理',
|
||||
)}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Text>
|
||||
(支持{' '}
|
||||
{t('仅支持')}{' '}
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-worker'
|
||||
target='_blank'
|
||||
@@ -705,7 +721,7 @@ const SystemSetting = () => {
|
||||
>
|
||||
new-api-worker
|
||||
</a>
|
||||
)
|
||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
@@ -1044,7 +1060,9 @@ const SystemSetting = () => {
|
||||
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
|
||||
description={t(
|
||||
'Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式',
|
||||
)}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Row
|
||||
@@ -1070,7 +1088,9 @@ const SystemSetting = () => {
|
||||
field="['passkey.rp_display_name']"
|
||||
label={t('服务显示名称')}
|
||||
placeholder={t('默认使用系统名称')}
|
||||
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
|
||||
extraText={t(
|
||||
"用户注册时看到的网站名称,比如'我的网站'",
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
@@ -1078,7 +1098,9 @@ const SystemSetting = () => {
|
||||
field="['passkey.rp_id']"
|
||||
label={t('网站域名标识')}
|
||||
placeholder={t('例如:example.com')}
|
||||
extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')}
|
||||
extraText={t(
|
||||
'留空则默认使用服务器地址,注意不能携带http://或者https://',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -1092,7 +1114,10 @@ const SystemSetting = () => {
|
||||
label={t('安全验证级别')}
|
||||
placeholder={t('是否要求指纹/面容等生物识别')}
|
||||
optionList={[
|
||||
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
|
||||
{
|
||||
label: t('推荐使用(用户可选)'),
|
||||
value: 'preferred',
|
||||
},
|
||||
{ label: t('强制要求'), value: 'required' },
|
||||
{ label: t('不建议使用'), value: 'discouraged' },
|
||||
]}
|
||||
@@ -1109,7 +1134,9 @@ const SystemSetting = () => {
|
||||
{ label: t('本设备内置'), value: 'platform' },
|
||||
{ label: t('外接设备'), value: 'cross-platform' },
|
||||
]}
|
||||
extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
|
||||
extraText={t(
|
||||
'本设备:手机指纹/面容,外接:USB安全密钥',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -1123,7 +1150,10 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.allow_insecure_origin', e)
|
||||
handleCheckboxChange(
|
||||
'passkey.allow_insecure_origin',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('允许不安全的 Origin(HTTP)')}
|
||||
@@ -1139,11 +1169,16 @@ const SystemSetting = () => {
|
||||
field="['passkey.origins']"
|
||||
label={t('允许的 Origins')}
|
||||
placeholder={t('填写带https的域名,逗号分隔')}
|
||||
extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')}
|
||||
extraText={t(
|
||||
'为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
onClick={submitPasskeySettings}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{t('保存 Passkey 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
|
||||
@@ -535,7 +535,9 @@ const AccountManagement = ({
|
||||
? () => {
|
||||
Modal.confirm({
|
||||
title: t('确认解绑 Passkey'),
|
||||
content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
|
||||
content: t(
|
||||
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
|
||||
),
|
||||
okText: t('确认解绑'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
@@ -547,7 +549,11 @@ const AccountManagement = ({
|
||||
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
|
||||
icon={<IconKey />}
|
||||
disabled={!passkeySupported && !passkeyEnabled}
|
||||
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
|
||||
loading={
|
||||
passkeyEnabled
|
||||
? passkeyDeleteLoading
|
||||
: passkeyRegisterLoading
|
||||
}
|
||||
>
|
||||
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||
</Button>
|
||||
|
||||
@@ -621,7 +621,9 @@ const NotificationSettings = ({
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: t('Gotify服务器地址必须以http://或https://开头'),
|
||||
message: t(
|
||||
'Gotify服务器地址必须以http://或https://开头',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -678,9 +680,7 @@ const NotificationSettings = ({
|
||||
'复制应用的令牌(Token)并填写到上方的应用令牌字段',
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
3. {t('填写Gotify服务器的完整URL地址')}
|
||||
</div>
|
||||
<div>3. {t('填写Gotify服务器的完整URL地址')}</div>
|
||||
<div className='mt-3 pt-3 border-t border-gray-200'>
|
||||
<span className='text-gray-400'>
|
||||
{t('更多信息请参考')}
|
||||
|
||||
@@ -25,29 +25,55 @@ 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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,8 +119,19 @@ const EditTagModal = (props) => {
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 53:
|
||||
localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
|
||||
break;
|
||||
localModels = [
|
||||
'NousResearch/Hermes-4-405B-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
||||
'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Instruct-2507',
|
||||
'zai-org/GLM-4.5-FP8',
|
||||
'openai/gpt-oss-120b',
|
||||
'deepseek-ai/DeepSeek-R1-0528',
|
||||
'deepseek-ai/DeepSeek-R1',
|
||||
'deepseek-ai/DeepSeek-V3-0324',
|
||||
'deepseek-ai/DeepSeek-V3.1',
|
||||
];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
|
||||
@@ -67,9 +67,15 @@ const ModelTestModal = ({
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{ value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Gemini (/v1beta/models/{model}:generateContent)',
|
||||
},
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
|
||||
{ value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
|
||||
{
|
||||
value: 'image-generation',
|
||||
label: t('图像生成') + ' (/v1/images/generations)',
|
||||
},
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
];
|
||||
|
||||
@@ -166,7 +172,13 @@ const ModelTestModal = ({
|
||||
return (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
|
||||
onClick={() =>
|
||||
testChannel(
|
||||
currentTestChannel,
|
||||
record.model,
|
||||
selectedEndpointType,
|
||||
)
|
||||
}
|
||||
loading={isTesting}
|
||||
size='small'
|
||||
>
|
||||
|
||||
@@ -279,16 +279,8 @@ const renderOperations = (
|
||||
>
|
||||
{t('降级')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={moreMenu}
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
>
|
||||
<Button
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconMore />}
|
||||
/>
|
||||
<Dropdown menu={moreMenu} trigger='click' position='bottomRight'>
|
||||
<Button type='tertiary' size='small' icon={<IconMore />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -30,10 +30,11 @@ const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
type='warning'
|
||||
>
|
||||
{t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
|
||||
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||
{user?.username
|
||||
? t('目标用户:{{username}}', { username: user.username })
|
||||
: ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasskeyModal;
|
||||
|
||||
|
||||
@@ -29,11 +29,14 @@ const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
onOk={onConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
|
||||
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||
{t(
|
||||
'此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。',
|
||||
)}{' '}
|
||||
{user?.username
|
||||
? t('目标用户:{{username}}', { username: user.username })
|
||||
: ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetTwoFAModal;
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ import {
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
CreditCard,
|
||||
Coins,
|
||||
Wallet,
|
||||
BarChart2,
|
||||
TrendingUp,
|
||||
Receipt,
|
||||
} from 'lucide-react';
|
||||
import { IconGift } from '@douyinfe/semi-icons';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
@@ -72,6 +79,7 @@ const RechargeCard = ({
|
||||
renderQuota,
|
||||
statusLoading,
|
||||
topupInfo,
|
||||
onOpenHistory,
|
||||
}) => {
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
@@ -79,16 +87,25 @@ const RechargeCard = ({
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<CreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('账户充值')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<CreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('账户充值')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon={<Receipt size={16} />}
|
||||
theme='solid'
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
{t('账单')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
@@ -339,16 +356,22 @@ const RechargeCard = ({
|
||||
)}
|
||||
|
||||
{(enableOnlineTopUp || enableStripeTopUp) && (
|
||||
<Form.Slot
|
||||
<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' }}>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
(1 $ = {rate.toFixed(2)} {symbol})
|
||||
</span>
|
||||
);
|
||||
@@ -378,11 +401,11 @@ const RechargeCard = ({
|
||||
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;
|
||||
@@ -444,7 +467,8 @@ const RechargeCard = ({
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{t('实付')} {symbol}{displayActualPay.toFixed(2)},
|
||||
{t('实付')} {symbol}
|
||||
{displayActualPay.toFixed(2)},
|
||||
{hasDiscount
|
||||
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
|
||||
: `${t('节省')} ${symbol}0.00`}
|
||||
|
||||
@@ -37,6 +37,7 @@ import RechargeCard from './RechargeCard';
|
||||
import InvitationCard from './InvitationCard';
|
||||
import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
|
||||
const TopUp = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -77,6 +78,9 @@ const TopUp = () => {
|
||||
const [openTransfer, setOpenTransfer] = useState(false);
|
||||
const [transferAmount, setTransferAmount] = useState(0);
|
||||
|
||||
// 账单Modal状态
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
@@ -488,6 +492,14 @@ const TopUp = () => {
|
||||
setOpenTransfer(false);
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
setOpenHistory(true);
|
||||
};
|
||||
|
||||
const handleHistoryCancel = () => {
|
||||
setOpenHistory(false);
|
||||
};
|
||||
|
||||
// 选择预设充值额度
|
||||
const selectPresetAmount = (preset) => {
|
||||
setTopUpCount(preset.value);
|
||||
@@ -544,6 +556,13 @@ const TopUp = () => {
|
||||
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
|
||||
/>
|
||||
|
||||
{/* 充值账单模态框 */}
|
||||
<TopupHistoryModal
|
||||
visible={openHistory}
|
||||
onCancel={handleHistoryCancel}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
@@ -580,6 +599,7 @@ const TopUp = () => {
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
273
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal file
273
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Badge,
|
||||
Typography,
|
||||
Toast,
|
||||
Empty,
|
||||
Button,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, timestamp2string } from '../../../helpers';
|
||||
import { isAdmin } from '../../../helpers/utils';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 状态映射配置
|
||||
const STATUS_CONFIG = {
|
||||
success: { type: 'success', key: '成功' },
|
||||
pending: { type: 'warning', key: '待支付' },
|
||||
expired: { type: 'danger', key: '已过期' },
|
||||
};
|
||||
|
||||
// 支付方式映射
|
||||
const PAYMENT_METHOD_MAP = {
|
||||
stripe: 'Stripe',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信',
|
||||
};
|
||||
|
||||
const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [topups, setTopups] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadTopups = async (currentPage, currentPageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
|
||||
const qs =
|
||||
`p=${currentPage}&page_size=${currentPageSize}` +
|
||||
(keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
|
||||
const endpoint = `${base}?${qs}`;
|
||||
const res = await API.get(endpoint);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setTopups(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} else {
|
||||
Toast.error({ content: message || t('加载失败') });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load topups error:', error);
|
||||
Toast.error({ content: t('加载账单失败') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadTopups(page, pageSize);
|
||||
}
|
||||
}, [visible, page, pageSize, keyword]);
|
||||
|
||||
const handlePageChange = (currentPage) => {
|
||||
setPage(currentPage);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (currentPageSize) => {
|
||||
setPageSize(currentPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleKeywordChange = (value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 管理员补单
|
||||
const handleAdminComplete = async (tradeNo) => {
|
||||
try {
|
||||
const res = await API.post('/api/user/topup/complete', {
|
||||
trade_no: tradeNo,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
Toast.success({ content: t('补单成功') });
|
||||
await loadTopups(page, pageSize);
|
||||
} else {
|
||||
Toast.error({ content: message || t('补单失败') });
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error({ content: t('补单失败') });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAdminComplete = (tradeNo) => {
|
||||
Modal.confirm({
|
||||
title: t('确认补单'),
|
||||
content: t('是否将该订单标记为成功并为用户入账?'),
|
||||
onOk: () => handleAdminComplete(tradeNo),
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染状态徽章
|
||||
const renderStatusBadge = (status) => {
|
||||
const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
|
||||
return (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Badge dot type={config.type} />
|
||||
<span>{t(config.key)}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染支付方式
|
||||
const renderPaymentMethod = (pm) => {
|
||||
const displayName = PAYMENT_METHOD_MAP[pm];
|
||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||
};
|
||||
|
||||
// 检查是否为管理员
|
||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
title: t('订单号'),
|
||||
dataIndex: 'trade_no',
|
||||
key: 'trade_no',
|
||||
render: (text) => <Text copyable>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('支付方式'),
|
||||
dataIndex: 'payment_method',
|
||||
key: 'payment_method',
|
||||
render: renderPaymentMethod,
|
||||
},
|
||||
{
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount) => (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('支付金额'),
|
||||
dataIndex: 'money',
|
||||
key: 'money',
|
||||
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: renderStatusBadge,
|
||||
},
|
||||
];
|
||||
|
||||
// 管理员才显示操作列
|
||||
if (userIsAdmin) {
|
||||
baseColumns.push({
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => {
|
||||
if (record.status !== 'pending') return null;
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => confirmAdminComplete(record.trade_no)}
|
||||
>
|
||||
{t('补单')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
baseColumns.push({
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'create_time',
|
||||
key: 'create_time',
|
||||
render: (time) => timestamp2string(time),
|
||||
});
|
||||
|
||||
return baseColumns;
|
||||
}, [t, userIsAdmin]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('充值账单')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('订单号')}
|
||||
value={keyword}
|
||||
onChange={handleKeywordChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={topups}
|
||||
loading={loading}
|
||||
rowKey='id'
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
onPageChange: handlePageChange,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
}}
|
||||
size='small'
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无充值记录')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopupHistoryModal;
|
||||
@@ -88,6 +88,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'purple',
|
||||
label: '智谱 GLM-4V',
|
||||
},
|
||||
{
|
||||
value: 27,
|
||||
color: 'blue',
|
||||
label: 'Perplexity',
|
||||
},
|
||||
{
|
||||
value: 24,
|
||||
color: 'orange',
|
||||
@@ -159,7 +164,7 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'purple',
|
||||
label: 'Vidu',
|
||||
},
|
||||
{
|
||||
{
|
||||
value: 53,
|
||||
color: 'blue',
|
||||
label: 'SubModel',
|
||||
@@ -169,6 +174,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'blue',
|
||||
label: '豆包视频',
|
||||
},
|
||||
{
|
||||
value: 55,
|
||||
color: 'green',
|
||||
label: 'Sora',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function base64UrlToBuffer(base64url) {
|
||||
if (!base64url) return new ArrayBuffer(0);
|
||||
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
|
||||
@@ -26,7 +44,11 @@ export function bufferToBase64Url(buffer) {
|
||||
}
|
||||
|
||||
export function prepareCredentialCreationOptions(payload) {
|
||||
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
|
||||
const options =
|
||||
payload?.publicKey ||
|
||||
payload?.PublicKey ||
|
||||
payload?.response ||
|
||||
payload?.Response;
|
||||
if (!options) {
|
||||
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
|
||||
}
|
||||
@@ -46,7 +68,10 @@ export function prepareCredentialCreationOptions(payload) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
|
||||
if (
|
||||
Array.isArray(options.attestationFormats) &&
|
||||
options.attestationFormats.length === 0
|
||||
) {
|
||||
delete publicKey.attestationFormats;
|
||||
}
|
||||
|
||||
@@ -54,7 +79,11 @@ export function prepareCredentialCreationOptions(payload) {
|
||||
}
|
||||
|
||||
export function prepareCredentialRequestOptions(payload) {
|
||||
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
|
||||
const options =
|
||||
payload?.publicKey ||
|
||||
payload?.PublicKey ||
|
||||
payload?.response ||
|
||||
payload?.Response;
|
||||
if (!options) {
|
||||
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
|
||||
}
|
||||
@@ -77,7 +106,10 @@ export function buildRegistrationResult(credential) {
|
||||
if (!credential) return null;
|
||||
|
||||
const { response } = credential;
|
||||
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
|
||||
const transports =
|
||||
typeof response.getTransports === 'function'
|
||||
? response.getTransports()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
@@ -107,7 +139,9 @@ export function buildAssertionResult(assertion) {
|
||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
signature: bufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
|
||||
userHandle: response.userHandle
|
||||
? bufferToBase64Url(response.userHandle)
|
||||
: null,
|
||||
},
|
||||
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
|
||||
};
|
||||
@@ -117,15 +151,22 @@ export async function isPasskeySupported() {
|
||||
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
|
||||
return false;
|
||||
}
|
||||
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
|
||||
if (
|
||||
typeof window.PublicKeyCredential.isConditionalMediationAvailable ===
|
||||
'function'
|
||||
) {
|
||||
try {
|
||||
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||
const available =
|
||||
await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||
if (available) return true;
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
|
||||
if (
|
||||
typeof window.PublicKeyCredential
|
||||
.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
|
||||
) {
|
||||
try {
|
||||
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch (error) {
|
||||
@@ -134,4 +175,3 @@ export async function isPasskeySupported() {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
FastGPT,
|
||||
Kling,
|
||||
Jimeng,
|
||||
Perplexity,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
import {
|
||||
@@ -309,6 +310,8 @@ export function getChannelIcon(channelType) {
|
||||
return <Xinference.Color size={iconSize} />;
|
||||
case 25: // Moonshot
|
||||
return <Moonshot size={iconSize} />;
|
||||
case 27: // Perplexity
|
||||
return <Perplexity.Color size={iconSize} />;
|
||||
case 20: // OpenRouter
|
||||
return <OpenRouter size={iconSize} />;
|
||||
case 19: // 360 智脑
|
||||
@@ -929,10 +932,10 @@ export function renderQuotaWithAmount(amount) {
|
||||
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 {
|
||||
@@ -950,7 +953,7 @@ export function getCurrencyConfig() {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
||||
return { symbol, rate, type: quotaDisplayType };
|
||||
}
|
||||
|
||||
@@ -1128,7 +1131,7 @@ export function renderModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
@@ -1177,13 +1180,16 @@ export function renderModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
|
||||
: '',
|
||||
})}
|
||||
{i18next.t(
|
||||
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
|
||||
{
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
|
||||
: '',
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
@@ -1311,27 +1317,27 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
@@ -1384,7 +1390,7 @@ export function renderLogContent(
|
||||
label: ratioLabel,
|
||||
useUserGroupRatio: useUserGroupRatio,
|
||||
} = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
@@ -1484,10 +1490,10 @@ export function renderAudioModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
|
||||
// 1 ratio = $0.002 / 1K tokens
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
@@ -1522,10 +1528,10 @@ export function renderAudioModelPrice(
|
||||
let audioPrice =
|
||||
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
|
||||
(audioCompletionTokens / 1000000) *
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
groupRatio;
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
groupRatio;
|
||||
let price = textPrice + audioPrice;
|
||||
return (
|
||||
<>
|
||||
@@ -1577,7 +1583,12 @@ export function renderAudioModelPrice(
|
||||
{
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
|
||||
total: (
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
rate
|
||||
).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
audioCompRatio: audioCompletionRatio,
|
||||
},
|
||||
@@ -1586,29 +1597,31 @@ export function renderAudioModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0
|
||||
? i18next.t(
|
||||
'文字提示 {{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,
|
||||
symbol: symbol,
|
||||
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
'文字提示 {{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,
|
||||
symbol: symbol,
|
||||
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(
|
||||
6,
|
||||
),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
@@ -1617,9 +1630,15 @@ export function renderAudioModelPrice(
|
||||
input: audioInputTokens,
|
||||
completion: audioCompletionTokens,
|
||||
symbol: symbol,
|
||||
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
|
||||
audioCompPrice:
|
||||
(audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
|
||||
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(
|
||||
6,
|
||||
),
|
||||
audioCompPrice: (
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
inputRatioPrice *
|
||||
rate
|
||||
).toFixed(6),
|
||||
total: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
@@ -1668,7 +1687,7 @@ export function renderClaudeModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
@@ -1757,37 +1776,39 @@ export function renderClaudeModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||
? i18next.t(
|
||||
'提示 {{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,
|
||||
symbol: symbol,
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
'提示 {{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,
|
||||
symbol: symbol,
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheCreationPrice: (
|
||||
cacheCreationRatioPrice * rate
|
||||
).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
||||
</article>
|
||||
@@ -1810,7 +1831,7 @@ export function renderClaudeLogContent(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function isVerificationRequiredError(error) {
|
||||
const verificationCodes = [
|
||||
'VERIFICATION_REQUIRED',
|
||||
'VERIFICATION_EXPIRED',
|
||||
'VERIFICATION_INVALID'
|
||||
'VERIFICATION_INVALID',
|
||||
];
|
||||
|
||||
return verificationCodes.includes(data.code);
|
||||
@@ -57,6 +57,6 @@ export function extractVerificationInfo(error) {
|
||||
return {
|
||||
code: data.code,
|
||||
message: data.message || '需要安全验证',
|
||||
required: true
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ 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);
|
||||
|
||||
@@ -31,11 +31,11 @@ import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||
* @param {string} options.successMessage - 成功提示消息
|
||||
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
|
||||
*/
|
||||
export const useSecureVerification = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
export const useSecureVerification = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
autoReset = true
|
||||
autoReset = true,
|
||||
} = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,7 +43,7 @@ export const useSecureVerification = ({
|
||||
const [verificationMethods, setVerificationMethods] = useState({
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false
|
||||
passkeySupported: false,
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
@@ -54,12 +54,13 @@ export const useSecureVerification = ({
|
||||
method: null, // '2fa' | 'passkey'
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null
|
||||
apiCall: null,
|
||||
});
|
||||
|
||||
// 检查可用的验证方式
|
||||
const checkVerificationMethods = useCallback(async () => {
|
||||
const methods = await SecureVerificationService.checkAvailableVerificationMethods();
|
||||
const methods =
|
||||
await SecureVerificationService.checkAvailableVerificationMethods();
|
||||
setVerificationMethods(methods);
|
||||
return methods;
|
||||
}, []);
|
||||
@@ -75,94 +76,108 @@ export const useSecureVerification = ({
|
||||
method: null,
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null
|
||||
apiCall: null,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// 开始验证流程
|
||||
const startVerification = useCallback(async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
const startVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
onError?.(new Error(errorMessage));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置默认验证方式
|
||||
let defaultMethod = preferredMethod;
|
||||
if (!defaultMethod) {
|
||||
if (methods.hasPasskey && methods.passkeySupported) {
|
||||
defaultMethod = 'passkey';
|
||||
} else if (methods.has2FA) {
|
||||
defaultMethod = '2fa';
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
onError?.(new Error(errorMessage));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setVerificationState(prev => ({
|
||||
...prev,
|
||||
method: defaultMethod,
|
||||
apiCall,
|
||||
title,
|
||||
description
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
// 设置默认验证方式
|
||||
let defaultMethod = preferredMethod;
|
||||
if (!defaultMethod) {
|
||||
if (methods.hasPasskey && methods.passkeySupported) {
|
||||
defaultMethod = 'passkey';
|
||||
} else if (methods.has2FA) {
|
||||
defaultMethod = '2fa';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [checkVerificationMethods, onError, t]);
|
||||
setVerificationState((prev) => ({
|
||||
...prev,
|
||||
method: defaultMethod,
|
||||
apiCall,
|
||||
title,
|
||||
description,
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
|
||||
return true;
|
||||
},
|
||||
[checkVerificationMethods, onError, t],
|
||||
);
|
||||
|
||||
// 执行验证
|
||||
const executeVerification = useCallback(async (method, code = '') => {
|
||||
if (!verificationState.apiCall) {
|
||||
showError(t('验证配置错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
showSuccess(successMessage);
|
||||
const executeVerification = useCallback(
|
||||
async (method, code = '') => {
|
||||
if (!verificationState.apiCall) {
|
||||
showError(t('验证配置错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess?.(result, method);
|
||||
setVerificationState((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
// 自动重置状态
|
||||
if (autoReset) {
|
||||
resetState();
|
||||
try {
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
showSuccess(successMessage);
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess?.(result, method);
|
||||
|
||||
// 自动重置状态
|
||||
if (autoReset) {
|
||||
resetState();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error.message || t('验证失败,请重试'));
|
||||
onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setVerificationState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error.message || t('验证失败,请重试'));
|
||||
onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setVerificationState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
|
||||
},
|
||||
[
|
||||
verificationState.apiCall,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
onError,
|
||||
autoReset,
|
||||
resetState,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// 设置验证码
|
||||
const setVerificationCode = useCallback((code) => {
|
||||
setVerificationState(prev => ({ ...prev, code }));
|
||||
setVerificationState((prev) => ({ ...prev, code }));
|
||||
}, []);
|
||||
|
||||
// 切换验证方式
|
||||
const switchVerificationMethod = useCallback((method) => {
|
||||
setVerificationState(prev => ({ ...prev, method, code: '' }));
|
||||
setVerificationState((prev) => ({ ...prev, method, code: '' }));
|
||||
}, []);
|
||||
|
||||
// 取消验证
|
||||
@@ -171,20 +186,29 @@ export const useSecureVerification = ({
|
||||
}, [resetState]);
|
||||
|
||||
// 检查是否可以使用某种验证方式
|
||||
const canUseMethod = useCallback((method) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verificationMethods.has2FA;
|
||||
case 'passkey':
|
||||
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [verificationMethods]);
|
||||
const canUseMethod = useCallback(
|
||||
(method) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verificationMethods.has2FA;
|
||||
case 'passkey':
|
||||
return (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[verificationMethods],
|
||||
);
|
||||
|
||||
// 获取推荐的验证方式
|
||||
const getRecommendedMethod = useCallback(() => {
|
||||
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
|
||||
if (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
) {
|
||||
return 'passkey';
|
||||
}
|
||||
if (verificationMethods.has2FA) {
|
||||
@@ -200,22 +224,25 @@ export const useSecureVerification = ({
|
||||
* @param {Object} options - 验证选项(同 startVerification)
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
const withVerification = useCallback(async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
const withVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
}, [startVerification]);
|
||||
},
|
||||
[startVerification],
|
||||
);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
@@ -238,9 +265,10 @@ export const useSecureVerification = ({
|
||||
withVerification, // 新增:自动处理验证的包装函数
|
||||
|
||||
// 便捷属性
|
||||
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
hasAnyVerificationMethod:
|
||||
verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
isLoading: verificationState.loading,
|
||||
currentMethod: verificationState.method,
|
||||
code: verificationState.code
|
||||
code: verificationState.code,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -377,6 +377,12 @@ export const useLogsData = () => {
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
if (logs[i]?.content) {
|
||||
expandDataLocal.push({
|
||||
key: t('其他详情'),
|
||||
value: logs[i].content,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
let modelMapped =
|
||||
|
||||
@@ -86,7 +86,7 @@ export const useUsersData = () => {
|
||||
};
|
||||
|
||||
// Search users with keyword and group
|
||||
const searchUsers = async (
|
||||
const searchUsers = async (
|
||||
startIdx,
|
||||
pageSize,
|
||||
searchKeyword = null,
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"邀请新用户奖励额度": "Referral bonus quota",
|
||||
"新用户使用邀请码奖励额度": "New user invitation code bonus quota",
|
||||
"保存额度设置": "Save Quota Settings",
|
||||
"分组与模型定价设置": "Group and Model Pricing Settings",
|
||||
"倍率设置": "Ratio Settings",
|
||||
"模型倍率": "Model ratio",
|
||||
"为一个 JSON 文本": "Is a JSON text",
|
||||
@@ -244,6 +245,8 @@
|
||||
"检查更新": "Check for updates",
|
||||
"公告": "Announcement",
|
||||
"在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Enter user agreement content here, supports Markdown & HTML code",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
|
||||
"保存公告": "Save Announcement",
|
||||
"个性化设置": "Personalization Settings",
|
||||
"系统名称": "System Name",
|
||||
@@ -1260,6 +1263,8 @@
|
||||
"仅修改展示粒度,统计精确到小时": "Only modify display granularity, statistics accurate to the hour",
|
||||
"当运行通道全部测试时,超过此时间将自动禁用通道": "When running all channel tests, the channel will be automatically disabled when this time is exceeded",
|
||||
"设置公告": "Set notice",
|
||||
"设置用户协议": "Set user agreement",
|
||||
"设置隐私政策": "Set privacy policy",
|
||||
"设置 Logo": "Set Logo",
|
||||
"设置首页内容": "Set home page content",
|
||||
"设置关于": "Set about",
|
||||
@@ -1285,7 +1290,6 @@
|
||||
"可视化倍率设置": "Visual model ratio settings",
|
||||
"确定重置模型倍率吗?": "Confirm to reset model ratio?",
|
||||
"模型固定价格": "Model price per call",
|
||||
"模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
|
||||
"保存模型倍率设置": "Save model ratio settings",
|
||||
"重置模型倍率": "Reset model ratio",
|
||||
"一次调用消耗多少刀,优先级大于模型倍率": "How much USD one call costs, priority over model ratio",
|
||||
@@ -2177,7 +2181,6 @@
|
||||
"最后使用时间": "Last used time",
|
||||
"备份支持": "Backup support",
|
||||
"支持备份": "Supported",
|
||||
"不支持": "Not supported",
|
||||
"备份状态": "Backup state",
|
||||
"已备份": "Backed up",
|
||||
"未备份": "Not backed up",
|
||||
@@ -2248,5 +2251,34 @@
|
||||
"轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented",
|
||||
"common": {
|
||||
"changeLanguage": "Change Language"
|
||||
}
|
||||
},
|
||||
"充值账单": "Recharge Bills",
|
||||
"订单号": "Order No.",
|
||||
"支付金额": "Payment Amount",
|
||||
"待支付": "Pending",
|
||||
"加载失败": "Load failed",
|
||||
"加载账单失败": "Failed to load bills",
|
||||
"暂无充值记录": "No recharge records",
|
||||
"账单": "Bills",
|
||||
"补单": "Complete Order",
|
||||
"补单成功": "Order completed successfully",
|
||||
"补单失败": "Failed to complete order",
|
||||
"确认补单": "Confirm Order Completion",
|
||||
"是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?",
|
||||
"用户协议": "User Agreement",
|
||||
"隐私政策": "Privacy Policy",
|
||||
"用户协议更新失败": "Failed to update user agreement",
|
||||
"隐私政策更新失败": "Failed to update privacy policy",
|
||||
"管理员未设置用户协议内容": "Administrator has not set user agreement content",
|
||||
"管理员未设置隐私政策内容": "Administrator has not set privacy policy content",
|
||||
"加载用户协议内容失败...": "Failed to load user agreement content...",
|
||||
"加载隐私政策内容失败...": "Failed to load privacy policy content...",
|
||||
"我已阅读并同意": "I have read and agree to",
|
||||
"和": " and ",
|
||||
"请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "After filling in the user agreement content, users will be required to check that they have read the user agreement when registering",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "After filling in the privacy policy content, users will be required to check that they have read the privacy policy when registering",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "Administrator has set an external link, click the button below to access",
|
||||
"访问用户协议": "Access User Agreement",
|
||||
"访问隐私政策": "Access Privacy Policy"
|
||||
}
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
"邀请新用户奖励额度": "Quota de bonus de parrainage",
|
||||
"新用户使用邀请码奖励额度": "Quota de bonus de code d'invitation pour nouvel utilisateur",
|
||||
"保存额度设置": "Enregistrer les paramètres de quota",
|
||||
"分组与模型定价设置": "Paramètres de groupe et de tarification du modèle",
|
||||
"倍率设置": "Paramètres de ratio",
|
||||
"模型倍率": "Ratio de modèle",
|
||||
"为一个 JSON 文本": "Est un texte JSON",
|
||||
@@ -2238,5 +2239,40 @@
|
||||
"配置 Passkey": "Configurer Passkey",
|
||||
"重置 2FA": "Réinitialiser 2FA",
|
||||
"重置 Passkey": "Réinitialiser le Passkey",
|
||||
"默认使用系统名称": "Le nom du système est utilisé par défaut"
|
||||
"默认使用系统名称": "Le nom du système est utilisé par défaut",
|
||||
"充值账单": "Factures de recharge",
|
||||
"订单号": "N° de commande",
|
||||
"支付金额": "Montant payé",
|
||||
"待支付": "En attente",
|
||||
"加载失败": "Échec du chargement",
|
||||
"加载账单失败": "Échec du chargement des factures",
|
||||
"暂无充值记录": "Aucune recharge",
|
||||
"账单": "Factures",
|
||||
"补单": "Compléter la commande",
|
||||
"补单成功": "Commande complétée avec succès",
|
||||
"补单失败": "Échec de la complétion de la commande",
|
||||
"确认补单": "Confirmer la complétion",
|
||||
"是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?",
|
||||
"用户协议": "Accord utilisateur",
|
||||
"隐私政策": "Politique de confidentialité",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de l'accord utilisateur, prend en charge le code Markdown et HTML",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de la politique de confidentialité, prend en charge le code Markdown et HTML",
|
||||
"设置用户协议": "Définir l'accord utilisateur",
|
||||
"设置隐私政策": "Définir la politique de confidentialité",
|
||||
"用户协议已更新": "L'accord utilisateur a été mis à jour",
|
||||
"隐私政策已更新": "La politique de confidentialité a été mise à jour",
|
||||
"用户协议更新失败": "Échec de la mise à jour de l'accord utilisateur",
|
||||
"隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité",
|
||||
"管理员未设置用户协议内容": "L'administrateur n'a pas défini le contenu de l'accord utilisateur",
|
||||
"管理员未设置隐私政策内容": "L'administrateur n'a pas défini le contenu de la politique de confidentialité",
|
||||
"加载用户协议内容失败...": "Échec du chargement du contenu de l'accord utilisateur...",
|
||||
"加载隐私政策内容失败...": "Échec du chargement du contenu de la politique de confidentialité...",
|
||||
"我已阅读并同意": "J'ai lu et j'accepte",
|
||||
"和": " et ",
|
||||
"请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "Après avoir rempli le contenu de l'accord utilisateur, les utilisateurs devront cocher qu'ils ont lu l'accord utilisateur lors de l'inscription",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "Après avoir rempli le contenu de la politique de confidentialité, les utilisateurs devront cocher qu'ils ont lu la politique de confidentialité lors de l'inscription",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "L'administrateur a défini un lien externe, cliquez sur le bouton ci-dessous pour accéder",
|
||||
"访问用户协议": "Accéder à l'accord utilisateur",
|
||||
"访问隐私政策": "Accéder à la politique de confidentialité"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user