mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-05 00:15:57 +00:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7437b671ef | ||
|
|
55d19df029 | ||
|
|
731e9f4ca9 | ||
|
|
cc6fcebda1 | ||
|
|
72a12e3747 | ||
|
|
0adfcf9d27 | ||
|
|
51d71a6e1a | ||
|
|
8026e5142b | ||
|
|
9e33c83351 | ||
|
|
d2492d2af9 | ||
|
|
c9abe1d769 | ||
|
|
a8bfa7ad29 | ||
|
|
93e30703d4 | ||
|
|
b39885be1e | ||
|
|
f24feed775 | ||
|
|
6b75bc0016 | ||
|
|
937d931442 | ||
|
|
5c6e6032ef | ||
|
|
69e1542fc9 | ||
|
|
3199e2e8cd | ||
|
|
15b21c075f | ||
|
|
922ecef31e | ||
|
|
573b5c3e3b | ||
|
|
39a868faea | ||
|
|
7c7f9abd04 | ||
|
|
050e0221c7 | ||
|
|
a57a36a739 | ||
|
|
0046282fb8 | ||
|
|
723eefe9d8 | ||
|
|
2488e6ab66 |
@@ -5,4 +5,5 @@
|
||||
.gitignore
|
||||
Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.gocache
|
||||
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report_en.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Describe the issue you encountered with clear and detailed language
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README, especially the FAQ section
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Issue Description**
|
||||
|
||||
**Steps to Reproduce**
|
||||
|
||||
**Expected Result**
|
||||
|
||||
**Related Screenshots**
|
||||
If none, please delete this section.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request_en.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Describe the new feature you would like to add with clear and detailed language
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Routine Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues currently
|
||||
+ [ ] I have confirmed I have upgraded to the latest version
|
||||
+ [ ] I have thoroughly read the project README and confirmed the current version cannot meet my needs
|
||||
+ [ ] I understand and am willing to follow up on this issue, assist with testing and provide feedback
|
||||
+ [ ] I understand and acknowledge the above, and understand that project maintainers have limited time and energy, **issues that do not follow the rules may be ignored or closed directly**
|
||||
|
||||
**Feature Description**
|
||||
|
||||
**Use Case**
|
||||
|
||||
@@ -13,7 +13,3 @@
|
||||
### PR 描述
|
||||
|
||||
**请在下方详细描述您的 PR,包括目的、实现细节等。**
|
||||
|
||||
### **重要提示**
|
||||
|
||||
**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。**
|
||||
|
||||
142
.github/workflows/electron-build.yml
vendored
Normal file
142
.github/workflows/electron-build.yml
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*' # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
# os: [macos-latest, windows-latest]
|
||||
os: [windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.18.0'
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
|
||||
# - name: Build Go binary (macos/Linux)
|
||||
# if: runner.os != 'Windows'
|
||||
# run: |
|
||||
# go mod download
|
||||
# go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
|
||||
- name: Build Go binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||
|
||||
- name: Update Electron version
|
||||
run: |
|
||||
cd electron
|
||||
VERSION=$(git describe --tags)
|
||||
VERSION=${VERSION#v} # Remove 'v' prefix if present
|
||||
# Convert to valid semver: take first 3 components and convert rest to prerelease format
|
||||
# e.g., 0.9.3-patch.1 -> 0.9.3-patch.1
|
||||
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(.*)$ ]]; then
|
||||
MAJOR=${BASH_REMATCH[1]}
|
||||
MINOR=${BASH_REMATCH[2]}
|
||||
PATCH=${BASH_REMATCH[3]}
|
||||
REST=${BASH_REMATCH[4]}
|
||||
|
||||
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
|
||||
# If there's extra content, append it without adding -dev
|
||||
if [[ -n "$REST" ]]; then
|
||||
VERSION="$VERSION$REST"
|
||||
fi
|
||||
fi
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Install Electron dependencies
|
||||
run: |
|
||||
cd electron
|
||||
npm install
|
||||
|
||||
# - name: Build Electron app (macOS)
|
||||
# if: runner.os == 'macOS'
|
||||
# run: |
|
||||
# cd electron
|
||||
# npm run build:mac
|
||||
# env:
|
||||
# CSC_IDENTITY_AUTO_DISCOVERY: false # Skip code signing
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
cd electron
|
||||
npm run build:win
|
||||
|
||||
# - name: Upload artifacts (macOS)
|
||||
# if: runner.os == 'macOS'
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: macos-build
|
||||
# path: |
|
||||
# electron/dist/*.dmg
|
||||
# electron/dist/*.zip
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-build
|
||||
path: |
|
||||
electron/dist/*.exe
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
windows-build/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
overwrite_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
11
.github/workflows/linux-release.yml
vendored
11
.github/workflows/linux-release.yml
vendored
@@ -38,21 +38,22 @@ jobs:
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
|
||||
- name: Build Backend (arm64)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64
|
||||
VERSION=$(git describe --tags)
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: |
|
||||
new-api
|
||||
new-api-arm64
|
||||
new-api-*
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
|
||||
7
.github/workflows/macos-release.yml
vendored
7
.github/workflows/macos-release.yml
vendored
@@ -39,12 +39,13 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: |
|
||||
go mod download
|
||||
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
|
||||
VERSION=$(git describe --tags)
|
||||
go build -ldflags "-X 'one-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: new-api-macos
|
||||
files: new-api-macos-*
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
|
||||
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
|
||||
32
README.en.md
32
README.en.md
@@ -89,22 +89,23 @@ New API offers a wide range of features, please refer to [Features Introduction]
|
||||
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. Support for entering chat interface via /chat2link route
|
||||
15. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Support for setting reasoning effort through model name suffixes:
|
||||
1. OpenAI o-series models
|
||||
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
||||
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
||||
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
||||
2. Claude thinking models
|
||||
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Thinking-to-content functionality
|
||||
17. 🔄 Model rate limiting for users
|
||||
18. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
17. 🔄 Thinking-to-content functionality
|
||||
18. 🔄 Model rate limiting for users
|
||||
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
||||
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
||||
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
||||
3. Supported channels:
|
||||
@@ -134,14 +135,12 @@ For detailed configuration instructions, please refer to [Installation Guide-Env
|
||||
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
||||
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
|
||||
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
||||
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
||||
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
||||
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
||||
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
||||
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
|
||||
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
|
||||
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
||||
@@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
```
|
||||
|
||||
## Channel Retry and Cache
|
||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
|
||||
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
|
||||
|
||||
### Cache Configuration Method
|
||||
1. `REDIS_CONN_STRING`: Set Redis as cache
|
||||
@@ -198,22 +197,21 @@ Channel retry functionality has been implemented, you can set the number of retr
|
||||
|
||||
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
||||
|
||||
- [Chat API](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Image API](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Related Projects
|
||||
- [One API](https://github.com/songquanpeng/one-api): Original project
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
||||
|
||||
Other projects based on New API:
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
|
||||
|
||||
## Help and Support
|
||||
|
||||
|
||||
32
README.fr.md
32
README.fr.md
@@ -89,22 +89,23 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à
|
||||
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
||||
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link
|
||||
15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||
1. Modèles de la série o d'OpenAI
|
||||
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
||||
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
||||
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
||||
2. Modèles de pensée de Claude
|
||||
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 Fonctionnalité de la pensée au contenu
|
||||
17. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
17. 🔄 Fonctionnalité de la pensée au contenu
|
||||
18. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||
3. Canaux pris en charge :
|
||||
@@ -134,14 +135,12 @@ Pour des instructions de configuration détaillées, veuillez vous référer à
|
||||
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
||||
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
||||
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
||||
- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
||||
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
||||
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
||||
- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE`
|
||||
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
||||
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
||||
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données
|
||||
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
|
||||
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
||||
@@ -188,7 +187,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
```
|
||||
|
||||
## Nouvelle tentative de canal et cache
|
||||
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**.
|
||||
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
|
||||
|
||||
### Méthode de configuration du cache
|
||||
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
||||
@@ -198,22 +197,21 @@ La fonctionnalité de nouvelle tentative de canal a été implémentée, vous po
|
||||
|
||||
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
||||
|
||||
- [API de discussion](https://docs.newapi.pro/api/openai-chat)
|
||||
- [API d'image](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
|
||||
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
|
||||
|
||||
## Projets connexes
|
||||
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
|
||||
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||
|
||||
Autres projets basés sur New API :
|
||||
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
|
||||
|
||||
## Aide et support
|
||||
|
||||
|
||||
18
README.ja.md
18
README.ja.md
@@ -89,22 +89,23 @@ New APIは豊富な機能を提供しています。詳細な機能について
|
||||
10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC)
|
||||
11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. /chat2linkルートを使用してチャット画面に入ることをサポート
|
||||
15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート:
|
||||
1. OpenAI oシリーズモデル
|
||||
- `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`)
|
||||
- `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`)
|
||||
- `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`)
|
||||
2. Claude思考モデル
|
||||
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考からコンテンツへの機能
|
||||
17. 🔄 ユーザーに対するモデルレート制限機能
|
||||
18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
17. 🔄 思考からコンテンツへの機能
|
||||
18. 🔄 ユーザーに対するモデルレート制限機能
|
||||
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
|
||||
1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定
|
||||
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
|
||||
3. サポートされているチャネル:
|
||||
@@ -196,7 +197,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
|
||||
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
|
||||
|
||||
- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [チャットインターフェース(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [レスポンスインターフェース(Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
|
||||
22
README.md
22
README.md
@@ -85,22 +85,23 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
||||
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
||||
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
14. 支持使用路由/chat2link进入聊天界面
|
||||
15. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
|
||||
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
||||
15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
|
||||
16. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
||||
1. OpenAI o系列模型
|
||||
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
||||
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
||||
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
||||
2. Claude 思考模型
|
||||
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
||||
16. 🔄 思考转内容功能
|
||||
17. 🔄 针对用户的模型限流功能
|
||||
18. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages
|
||||
17. 🔄 思考转内容功能
|
||||
18. 🔄 针对用户的模型限流功能
|
||||
19. 🔄 请求格式转换功能,支持以下三种格式转换:
|
||||
1. OpenAI Chat Completions => Claude Messages (OpenAI格式调用Claude模型)
|
||||
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
||||
3. OpenAI Chat Completions => Gemini Chat
|
||||
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
3. OpenAI Chat Completions => Gemini Chat (OpenAI格式调用Gemini模型)
|
||||
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
||||
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
||||
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
||||
3. 支持的渠道:
|
||||
@@ -192,7 +193,8 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234
|
||||
|
||||
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
||||
|
||||
- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [聊天接口(Chat Completions)](https://docs.newapi.pro/api/openai-chat)
|
||||
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
|
||||
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
||||
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
||||
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
||||
|
||||
@@ -19,6 +19,7 @@ var TopUpLink = ""
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||
var DisplayInCurrencyEnabled = true
|
||||
var DisplayTokenStatEnabled = true
|
||||
var DrawingEnabled = true
|
||||
|
||||
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
||||
var UsingMySQL = false
|
||||
var UsingClickHouse = false
|
||||
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
APITypeXai
|
||||
APITypeCoze
|
||||
APITypeJimeng
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
APITypeMoonshot
|
||||
APITypeSubmodel
|
||||
APITypeDummy // this one is only for count, do not add any channel after this
|
||||
)
|
||||
|
||||
@@ -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,4 +113,67 @@ 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{
|
||||
ChannelTypeUnknown: "Unknown",
|
||||
ChannelTypeOpenAI: "OpenAI",
|
||||
ChannelTypeMidjourney: "Midjourney",
|
||||
ChannelTypeAzure: "Azure",
|
||||
ChannelTypeOllama: "Ollama",
|
||||
ChannelTypeMidjourneyPlus: "MidjourneyPlus",
|
||||
ChannelTypeOpenAIMax: "OpenAIMax",
|
||||
ChannelTypeOhMyGPT: "OhMyGPT",
|
||||
ChannelTypeCustom: "Custom",
|
||||
ChannelTypeAILS: "AILS",
|
||||
ChannelTypeAIProxy: "AIProxy",
|
||||
ChannelTypePaLM: "PaLM",
|
||||
ChannelTypeAPI2GPT: "API2GPT",
|
||||
ChannelTypeAIGC2D: "AIGC2D",
|
||||
ChannelTypeAnthropic: "Anthropic",
|
||||
ChannelTypeBaidu: "Baidu",
|
||||
ChannelTypeZhipu: "Zhipu",
|
||||
ChannelTypeAli: "Ali",
|
||||
ChannelTypeXunfei: "Xunfei",
|
||||
ChannelType360: "360",
|
||||
ChannelTypeOpenRouter: "OpenRouter",
|
||||
ChannelTypeAIProxyLibrary: "AIProxyLibrary",
|
||||
ChannelTypeFastGPT: "FastGPT",
|
||||
ChannelTypeTencent: "Tencent",
|
||||
ChannelTypeGemini: "Gemini",
|
||||
ChannelTypeMoonshot: "Moonshot",
|
||||
ChannelTypeZhipu_v4: "ZhipuV4",
|
||||
ChannelTypePerplexity: "Perplexity",
|
||||
ChannelTypeLingYiWanWu: "LingYiWanWu",
|
||||
ChannelTypeAws: "AWS",
|
||||
ChannelTypeCohere: "Cohere",
|
||||
ChannelTypeMiniMax: "MiniMax",
|
||||
ChannelTypeSunoAPI: "SunoAPI",
|
||||
ChannelTypeDify: "Dify",
|
||||
ChannelTypeJina: "Jina",
|
||||
ChannelCloudflare: "Cloudflare",
|
||||
ChannelTypeSiliconFlow: "SiliconFlow",
|
||||
ChannelTypeVertexAi: "VertexAI",
|
||||
ChannelTypeMistral: "Mistral",
|
||||
ChannelTypeDeepSeek: "DeepSeek",
|
||||
ChannelTypeMokaAI: "MokaAI",
|
||||
ChannelTypeVolcEngine: "VolcEngine",
|
||||
ChannelTypeBaiduV2: "BaiduV2",
|
||||
ChannelTypeXinference: "Xinference",
|
||||
ChannelTypeXai: "xAI",
|
||||
ChannelTypeCoze: "Coze",
|
||||
ChannelTypeKling: "Kling",
|
||||
ChannelTypeJimeng: "Jimeng",
|
||||
ChannelTypeVidu: "Vidu",
|
||||
ChannelTypeSubmodel: "Submodel",
|
||||
ChannelTypeDoubaoVideo: "DoubaoVideo",
|
||||
ChannelTypeSora: "Sora",
|
||||
}
|
||||
|
||||
func GetChannelTypeName(channelType int) string {
|
||||
if name, ok := ChannelTypeNames[channelType]; ok {
|
||||
return name
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/model"
|
||||
"one-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func GetSubscription(c *gin.Context) {
|
||||
@@ -39,8 +40,18 @@ func GetSubscription(c *gin.Context) {
|
||||
}
|
||||
quota := remainQuota + usedQuota
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
// OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值:
|
||||
// 我们将其解释为以“站点展示类型”为准:
|
||||
// - USD: 直接除以 QuotaPerUnit
|
||||
// - CNY: 先转 USD 再乘汇率
|
||||
// - TOKENS: 直接使用 tokens 数量
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// amount 保持 tokens 数值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
if token != nil && token.UnlimitedQuota {
|
||||
amount = 100000000
|
||||
@@ -80,8 +91,13 @@ func GetUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := float64(quota)
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
amount /= common.QuotaPerUnit
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
// tokens 保持原值
|
||||
default:
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
usage := OpenAIUsageResponse{
|
||||
Object: "list",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,51 +41,39 @@ type testResult struct {
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string) testResult {
|
||||
tik := time.Now()
|
||||
if channel.Type == constant.ChannelTypeMidjourney {
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
constant.ChannelTypeMidjourneyPlus,
|
||||
constant.ChannelTypeSunoAPI,
|
||||
constant.ChannelTypeKling,
|
||||
constant.ChannelTypeJimeng,
|
||||
constant.ChannelTypeDoubaoVideo,
|
||||
constant.ChannelTypeVidu,
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeMidjourneyPlus {
|
||||
if lo.Contains(unsupportedTestChannelTypes, channel.Type) {
|
||||
channelTypeName := constant.GetChannelTypeName(channel.Type)
|
||||
return testResult{
|
||||
localErr: errors.New("midjourney plus channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeSunoAPI {
|
||||
return testResult{
|
||||
localErr: errors.New("suno channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeKling {
|
||||
return testResult{
|
||||
localErr: errors.New("kling channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeJimeng {
|
||||
return testResult{
|
||||
localErr: errors.New("jimeng channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeDoubaoVideo {
|
||||
return testResult{
|
||||
localErr: errors.New("doubao video channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
}
|
||||
}
|
||||
if channel.Type == constant.ChannelTypeVidu {
|
||||
return testResult{
|
||||
localErr: errors.New("vidu channel test is not supported"),
|
||||
newAPIError: nil,
|
||||
localErr: fmt.Errorf("%s channel test is not supported", channelTypeName),
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
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"
|
||||
|
||||
// 如果指定了端点类型,使用指定的端点类型
|
||||
@@ -116,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{
|
||||
|
||||
@@ -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,
|
||||
@@ -66,18 +67,22 @@ func GetStatus(c *gin.Context) {
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
// 兼容旧前端:保留 display_in_currency,同时提供新的 quota_display_type
|
||||
"display_in_currency": operation_setting.IsCurrencyDisplay(),
|
||||
"quota_display_type": operation_setting.GetQuotaDisplayType(),
|
||||
"custom_currency_symbol": operation_setting.GetGeneralSetting().CustomCurrencySymbol,
|
||||
"custom_currency_exchange_rate": operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
"price": operation_setting.Price,
|
||||
@@ -104,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 != "",
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
@@ -147,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()
|
||||
|
||||
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -86,8 +86,9 @@ func GetEpayClient() *epay.Client {
|
||||
|
||||
func getPayMoney(amount int64, group string) float64 {
|
||||
dAmount := decimal.NewFromInt(amount)
|
||||
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
// 充值金额以“展示类型”为准:
|
||||
// - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
dAmount = dAmount.Div(dQuotaPerUnit)
|
||||
}
|
||||
@@ -115,7 +116,7 @@ func getPayMoney(amount int64, group string) float64 {
|
||||
|
||||
func getMinTopup() int64 {
|
||||
minTopup := operation_setting.MinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dMinTopup := decimal.NewFromInt(int64(minTopup))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
|
||||
@@ -176,18 +177,19 @@ func RequestEpay(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
amount := req.Amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dAmount := decimal.NewFromInt(int64(amount))
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
amount = dAmount.Div(dQuotaPerUnit).IntPart()
|
||||
}
|
||||
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 {
|
||||
@@ -235,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 {
|
||||
@@ -312,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 {
|
||||
@@ -258,7 +259,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
|
||||
|
||||
func getStripePayMoney(amount float64, group string) float64 {
|
||||
originalAmount := amount
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
amount = amount / common.QuotaPerUnit
|
||||
}
|
||||
// Using float64 for monetary calculations is acceptable here due to the small amounts involved
|
||||
@@ -279,7 +280,7 @@ func getStripePayMoney(amount float64, group string) float64 {
|
||||
|
||||
func getStripeMinTopup() int64 {
|
||||
minTopup := setting.StripeMinTopUp
|
||||
if !common.DisplayInCurrencyEnabled {
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
minTopup = minTopup * int(common.QuotaPerUnit)
|
||||
}
|
||||
return int64(minTopup)
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
72
docs/translation-glossary.md
Normal file
72
docs/translation-glossary.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 翻译术语表 (Translation Glossary)
|
||||
|
||||
本文档为翻译贡献者提供项目中关键术语的标准翻译参考,以确保翻译的一致性和准确性。
|
||||
|
||||
This document provides standard translation references for key terminology in the project to ensure consistency and accuracy for translation contributors.
|
||||
|
||||
## 核心概念 (Core Concepts)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 倍率 | Ratio | 用于计算价格的乘数因子 | Multiplier factor used for price calculation |
|
||||
| 令牌 | Token | API访问凭证,也指模型处理的文本单元 | API access credentials or text units processed by models |
|
||||
| 渠道 | Channel | API服务提供商的接入通道 | Access channel for API service providers |
|
||||
| 分组 | Group | 用户或令牌的分类,影响价格倍率 | Classification of users or tokens, affecting price ratios |
|
||||
| 额度 | Quota | 用户可用的服务额度 | Available service quota for users |
|
||||
|
||||
## 模型相关 (Model Related)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 提示 | Prompt | 模型输入内容 | Model input content |
|
||||
| 补全 | Completion | 模型输出内容 | Model output content |
|
||||
| 输入 | Input/Prompt | 发送给模型的内容 | Content sent to the model |
|
||||
| 输出 | Output/Completion | 模型返回的内容 | Content returned by the model |
|
||||
| 模型倍率 | Model Ratio | 不同模型的计费倍率 | Billing ratio for different models |
|
||||
| 补全倍率 | Completion Ratio | 输出内容的额外计费倍率 | Additional billing ratio for output content |
|
||||
| 固定价格 | Price per call | 按次计费的价格 | Fixed price per call |
|
||||
| 按量计费 | Pay-as-you-go | 根据使用量计费 | Billing based on usage |
|
||||
| 按次计费 | Pay-per-view | 每次调用固定价格 | Fixed price per invocation |
|
||||
|
||||
## 用户管理 (User Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 超级管理员 | Root User | 最高权限管理员 | Administrator with highest privileges |
|
||||
| 管理员 | Admin User | 系统管理员 | System administrator |
|
||||
| 普通用户 | Normal User | 普通权限用户 | Regular user with standard privileges |
|
||||
|
||||
## 充值与兑换 (Recharge & Redemption)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 充值 | Top Up | 为账户增加额度 | Add quota to account |
|
||||
| 兑换码 | Redemption Code | 可兑换额度的代码 | Code that can be redeemed for quota |
|
||||
|
||||
## 渠道管理 (Channel Management)
|
||||
|
||||
| 中文 | English | 说明 | Description |
|
||||
|------|---------|------|-------------|
|
||||
| 渠道 | Channel | API服务提供通道 | API service provider channel |
|
||||
| 密钥 | Key | API访问密钥 | API access key |
|
||||
| 优先级 | Priority | 渠道选择优先级 | Channel selection priority |
|
||||
| 权重 | Weight | 负载均衡权重 | Load balancing weight |
|
||||
| 代理 | Proxy | 代理服务器地址 | Proxy server address |
|
||||
| 模型重定向 | Model Mapping | 请求体中模型名称替换 | Model name replacement in request body |
|
||||
|
||||
## 翻译注意事项 (Translation Guidelines)
|
||||
|
||||
- **提示 (Prompt)** = 模型输入内容 / Model input content
|
||||
- **补全 (Completion)** = 模型输出内容 / Model output content
|
||||
- **倍率 (Ratio)** = 价格计算的乘数因子 / Multiplier factor for price calculation
|
||||
- **额度 (Quota)** = 可用的用户服务额度,有时也翻译为 Credit / Available service quota for users, sometimes also translated as Credit
|
||||
- **Token** = 根据上下文可能指 / Depending on context, may refer to:
|
||||
- API访问令牌 (API Token)
|
||||
- 模型处理的文本单元 (Text Token)
|
||||
- 系统访问令牌 (Access Token)
|
||||
|
||||
---
|
||||
|
||||
**贡献说明**: 如发现术语翻译不一致或有更好的翻译建议,欢迎提交 Issue 或 Pull Request。
|
||||
|
||||
**Contribution Note**: If you find any inconsistencies in terminology translations or have better translation suggestions, please feel free to submit an Issue or Pull Request.
|
||||
@@ -293,12 +293,13 @@ type GeminiChatSafetyRating struct {
|
||||
|
||||
type GeminiChatPromptFeedback struct {
|
||||
SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"`
|
||||
BlockReason *string `json:"blockReason,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiChatResponse struct {
|
||||
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||
PromptFeedback GeminiChatPromptFeedback `json:"promptFeedback"`
|
||||
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
||||
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
|
||||
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
@@ -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 {
|
||||
|
||||
@@ -74,14 +74,15 @@ func (r ImageRequest) MarshalJSON() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 不能合并ExtraFields!!!!!!!!
|
||||
// 合并 ExtraFields
|
||||
for k, v := range r.Extra {
|
||||
if _, exists := baseMap[k]; !exists {
|
||||
baseMap[k] = v
|
||||
}
|
||||
}
|
||||
//for k, v := range r.Extra {
|
||||
// if _, exists := baseMap[k]; !exists {
|
||||
// baseMap[k] = v
|
||||
// }
|
||||
//}
|
||||
|
||||
return json.Marshal(baseMap)
|
||||
return common.Marshal(baseMap)
|
||||
}
|
||||
|
||||
func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"one-api/common"
|
||||
"one-api/setting/operation_setting"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -92,18 +93,55 @@ func logHelper(ctx context.Context, level string, msg string) {
|
||||
}
|
||||
|
||||
func LogQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
// 新逻辑:根据额度展示类型输出
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f 额度", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f 额度", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d 点额度", quota)
|
||||
default: // USD
|
||||
return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatQuota(quota int) string {
|
||||
if common.DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit)
|
||||
} else {
|
||||
q := float64(quota)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
usd := q / common.QuotaPerUnit
|
||||
cny := usd * operation_setting.USDExchangeRate
|
||||
return fmt.Sprintf("¥%.6f", cny)
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
usd := q / common.QuotaPerUnit
|
||||
rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate
|
||||
symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol
|
||||
if symbol == "" {
|
||||
symbol = "¤"
|
||||
}
|
||||
if rate <= 0 {
|
||||
rate = 1
|
||||
}
|
||||
v := usd * rate
|
||||
return fmt.Sprintf("%s%.6f", symbol, v)
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return fmt.Sprintf("%d", quota)
|
||||
default:
|
||||
return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,30 @@ 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
|
||||
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 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"`
|
||||
|
||||
|
||||
@@ -240,7 +240,15 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
case "LogConsumeEnabled":
|
||||
common.LogConsumeEnabled = boolValue
|
||||
case "DisplayInCurrencyEnabled":
|
||||
common.DisplayInCurrencyEnabled = boolValue
|
||||
// 兼容旧字段:同步到新配置 general_setting.quota_display_type(运行时生效)
|
||||
// true -> USD, false -> TOKENS
|
||||
newVal := "USD"
|
||||
if !boolValue {
|
||||
newVal = "TOKENS"
|
||||
}
|
||||
if cfg := config.GlobalConfig.Get("general_setting"); cfg != nil {
|
||||
_ = config.UpdateConfigFromMap(cfg, map[string]string{"quota_display_type": newVal})
|
||||
}
|
||||
case "DisplayTokenStatEnabled":
|
||||
common.DisplayTokenStatEnabled = boolValue
|
||||
case "DrawingEnabled":
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||
openaiAdaptor := openai.Adaptor{}
|
||||
@@ -33,17 +35,25 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
if info.RelayMode == relayconstant.RelayModeEmbeddings {
|
||||
return info.ChannelBaseUrl + "/api/embed", nil
|
||||
}
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return info.ChannelBaseUrl + "/api/generate", nil
|
||||
}
|
||||
return info.ChannelBaseUrl + "/api/chat", nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||
@@ -53,7 +63,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request == nil { return nil, errors.New("request is nil") }
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
// decide generate or chat
|
||||
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||
return openAIToGenerate(c, request)
|
||||
@@ -69,7 +81,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
return requestOpenAI2Embeddings(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
return channel.DoApiRequest(a, c, info, requestBody)
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
)
|
||||
|
||||
type OllamaChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaToolFunction struct {
|
||||
@@ -20,7 +20,7 @@ type OllamaToolFunction struct {
|
||||
}
|
||||
|
||||
type OllamaTool struct {
|
||||
Type string `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Function OllamaToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
@@ -43,28 +43,27 @@ type OllamaChatRequest struct {
|
||||
}
|
||||
|
||||
type OllamaGenerateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Format interface{} `json:"format,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Input interface{} `json:"input"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
type OllamaEmbeddingResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Embeddings [][]float64 `json:"embeddings"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
|
||||
// options mapping
|
||||
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
|
||||
if r.Temperature != nil {
|
||||
chatReq.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
chatReq.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
chatReq.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
chatReq.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
chatReq.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
chatReq.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
chatReq.Options["num_predict"] = int(mt)
|
||||
}
|
||||
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
@@ -50,21 +64,27 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
case []string:
|
||||
chatReq.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string,0,len(v))
|
||||
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
|
||||
if len(arr)>0 { chatReq.Options["stop"] = arr }
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
chatReq.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Tools) > 0 {
|
||||
tools := make([]OllamaTool,0,len(r.Tools))
|
||||
tools := make([]OllamaTool, 0, len(r.Tools))
|
||||
for _, t := range r.Tools {
|
||||
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
||||
}
|
||||
chatReq.Tools = tools
|
||||
}
|
||||
|
||||
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
|
||||
chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages))
|
||||
for _, m := range r.Messages {
|
||||
var textBuilder strings.Builder
|
||||
var images []string
|
||||
@@ -79,14 +99,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
var base64Data string
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||
if err != nil { return nil, err }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base64Data = fileData.Base64Data
|
||||
} else if strings.HasPrefix(img.Url, "data:") {
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
|
||||
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
|
||||
base64Data = img.Url[idx+1:]
|
||||
}
|
||||
} else {
|
||||
base64Data = img.Url
|
||||
}
|
||||
if base64Data != "" { images = append(images, base64Data) }
|
||||
if base64Data != "" {
|
||||
images = append(images, base64Data)
|
||||
}
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeText {
|
||||
textBuilder.WriteString(part.Text)
|
||||
@@ -94,16 +120,24 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
}
|
||||
}
|
||||
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
||||
if len(images)>0 { cm.Images = images }
|
||||
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
|
||||
if len(images) > 0 {
|
||||
cm.Images = images
|
||||
}
|
||||
if m.Role == "tool" && m.Name != nil {
|
||||
cm.ToolName = *m.Name
|
||||
}
|
||||
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
||||
parsed := m.ParseToolCalls()
|
||||
if len(parsed) > 0 {
|
||||
calls := make([]OllamaToolCall,0,len(parsed))
|
||||
calls := make([]OllamaToolCall, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
var args interface{}
|
||||
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
|
||||
if args==nil { args = map[string]any{} }
|
||||
if tc.Function.Arguments != "" {
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
oc := OllamaToolCall{}
|
||||
oc.Function.Name = tc.Function.Name
|
||||
oc.Function.Arguments = args
|
||||
@@ -132,28 +166,67 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
gen.Prompt = v
|
||||
case []any:
|
||||
var sb strings.Builder
|
||||
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
|
||||
for _, it := range v {
|
||||
if s, ok := it.(string); ok {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
gen.Prompt = sb.String()
|
||||
default:
|
||||
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
||||
}
|
||||
}
|
||||
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
|
||||
if r.Suffix != nil {
|
||||
if s, ok := r.Suffix.(string); ok {
|
||||
gen.Suffix = s
|
||||
}
|
||||
}
|
||||
if r.ResponseFormat != nil {
|
||||
if r.ResponseFormat.Type == "json" {
|
||||
gen.Format = "json"
|
||||
} else if r.ResponseFormat.Type == "json_schema" {
|
||||
var schema any
|
||||
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
|
||||
gen.Format = schema
|
||||
}
|
||||
}
|
||||
if r.Temperature != nil {
|
||||
gen.Options["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
gen.Options["top_p"] = r.TopP
|
||||
}
|
||||
if r.TopK != 0 {
|
||||
gen.Options["top_k"] = r.TopK
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
gen.Options["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
gen.Options["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
gen.Options["seed"] = int(r.Seed)
|
||||
}
|
||||
if mt := r.GetMaxTokens(); mt != 0 {
|
||||
gen.Options["num_predict"] = int(mt)
|
||||
}
|
||||
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
|
||||
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
|
||||
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
|
||||
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
|
||||
if r.Stop != nil {
|
||||
switch v := r.Stop.(type) {
|
||||
case string: gen.Options["stop"] = []string{v}
|
||||
case []string: gen.Options["stop"] = v
|
||||
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
|
||||
case string:
|
||||
gen.Options["stop"] = []string{v}
|
||||
case []string:
|
||||
gen.Options["stop"] = v
|
||||
case []any:
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, i := range v {
|
||||
if s, ok := i.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
gen.Options["stop"] = arr
|
||||
}
|
||||
}
|
||||
}
|
||||
return gen, nil
|
||||
@@ -161,30 +234,51 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener
|
||||
|
||||
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
||||
opts := map[string]any{}
|
||||
if r.Temperature != nil { opts["temperature"] = r.Temperature }
|
||||
if r.TopP != 0 { opts["top_p"] = r.TopP }
|
||||
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
|
||||
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
|
||||
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
|
||||
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
|
||||
if r.Temperature != nil {
|
||||
opts["temperature"] = r.Temperature
|
||||
}
|
||||
if r.TopP != 0 {
|
||||
opts["top_p"] = r.TopP
|
||||
}
|
||||
if r.FrequencyPenalty != 0 {
|
||||
opts["frequency_penalty"] = r.FrequencyPenalty
|
||||
}
|
||||
if r.PresencePenalty != 0 {
|
||||
opts["presence_penalty"] = r.PresencePenalty
|
||||
}
|
||||
if r.Seed != 0 {
|
||||
opts["seed"] = int(r.Seed)
|
||||
}
|
||||
if r.Dimensions != 0 {
|
||||
opts["dimensions"] = r.Dimensions
|
||||
}
|
||||
input := r.ParseInput()
|
||||
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
|
||||
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
|
||||
if len(input) == 1 {
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input[0], Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
return &OllamaEmbeddingRequest{Model: r.Model, Input: input, Options: opts, Dimensions: r.Dimensions}
|
||||
}
|
||||
|
||||
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
var oResp OllamaEmbeddingResponse
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
|
||||
if err = common.Unmarshal(body, &oResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if oResp.Error != "" {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
data := make([]dto.OpenAIEmbeddingResponseItem, 0, len(oResp.Embeddings))
|
||||
for i, emb := range oResp.Embeddings {
|
||||
data = append(data, dto.OpenAIEmbeddingResponseItem{Index: i, Object: "embedding", Embedding: emb})
|
||||
}
|
||||
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens: 0, TotalTokens: oResp.PromptEvalCount}
|
||||
embResp := &dto.OpenAIEmbeddingResponse{Object: "list", Data: data, Model: info.UpstreamModelName, Usage: *usage}
|
||||
out, _ := common.Marshal(embResp)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,210 +1,278 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/types"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ollamaChatStreamChunk struct {
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
Model string `json:"model"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// chat
|
||||
Message *struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking json.RawMessage `json:"thinking"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments interface{} `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
// generate
|
||||
Response string `json:"response"`
|
||||
Done bool `json:"done"`
|
||||
DoneReason string `json:"done_reason"`
|
||||
TotalDuration int64 `json:"total_duration"`
|
||||
LoadDuration int64 `json:"load_duration"`
|
||||
PromptEvalCount int `json:"prompt_eval_count"`
|
||||
EvalCount int `json:"eval_count"`
|
||||
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||
EvalDuration int64 `json:"eval_duration"`
|
||||
}
|
||||
|
||||
func toUnix(ts string) int64 {
|
||||
if ts == "" { return time.Now().Unix() }
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
|
||||
return t.Unix()
|
||||
if ts == "" {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
// try time.RFC3339 or with nanoseconds
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
t2, err2 := time.Parse(time.RFC3339, ts)
|
||||
if err2 == nil {
|
||||
return t2.Unix()
|
||||
}
|
||||
return time.Now().Unix()
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest)
|
||||
}
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
helper.SetEventStreamHeaders(c)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
usage := &dto.Usage{}
|
||||
var model = info.UpstreamModelName
|
||||
var responseId = common.GetUUID()
|
||||
var created = time.Now().Unix()
|
||||
var toolCallIndex int
|
||||
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||
if data, err := common.Marshal(start); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" { continue }
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" { model = chunk.Model }
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var chunk ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if chunk.Model != "" {
|
||||
model = chunk.Model
|
||||
}
|
||||
created = toUnix(chunk.CreatedAt)
|
||||
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
|
||||
} },
|
||||
}
|
||||
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
|
||||
return usage, nil
|
||||
if !chunk.Done {
|
||||
// delta content
|
||||
var content string
|
||||
if chunk.Message != nil {
|
||||
content = chunk.Message.Content
|
||||
} else {
|
||||
content = chunk.Response
|
||||
}
|
||||
delta := dto.ChatCompletionsStreamResponse{
|
||||
Id: responseId,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: created,
|
||||
Model: model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Index: 0,
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"},
|
||||
}},
|
||||
}
|
||||
if content != "" {
|
||||
delta.Choices[0].Delta.SetContentString(content)
|
||||
}
|
||||
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
delta.Choices[0].Delta.SetReasoningContent(raw)
|
||||
}
|
||||
}
|
||||
// tool calls
|
||||
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls))
|
||||
for _, tc := range chunk.Message.ToolCalls {
|
||||
// arguments -> string
|
||||
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||
tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||
tr.SetIndex(toolCallIndex)
|
||||
toolCallIndex++
|
||||
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||
}
|
||||
}
|
||||
if data, err := common.Marshal(delta); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// done frame
|
||||
// finalize once and break loop
|
||||
usage.PromptTokens = chunk.PromptEvalCount
|
||||
usage.CompletionTokens = chunk.EvalCount
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
finishReason := chunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
// emit stop delta
|
||||
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||
if data, err := common.Marshal(stop); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// emit usage frame
|
||||
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||
if data, err := common.Marshal(final); err == nil {
|
||||
_ = helper.StringData(c, string(data))
|
||||
}
|
||||
}
|
||||
// send [DONE]
|
||||
helper.Done(c)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
logger.LogError(c, "ollama stream scan error: "+err.Error())
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// non-stream handler for chat/generate
|
||||
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
raw := string(body)
|
||||
if common.DebugEnabled {
|
||||
println("ollama non-stream raw resp:", raw)
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" { continue }
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
|
||||
}
|
||||
lines := strings.Split(raw, "\n")
|
||||
var (
|
||||
aggContent strings.Builder
|
||||
reasoningBuilder strings.Builder
|
||||
lastChunk ollamaChatStreamChunk
|
||||
parsedAny bool
|
||||
)
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
var ck ollamaChatStreamChunk
|
||||
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||
if len(lines) == 1 {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parsedAny = true
|
||||
lastChunk = ck
|
||||
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
if ck.Message != nil && ck.Message.Content != "" {
|
||||
aggContent.WriteString(ck.Message.Content)
|
||||
} else if ck.Response != "" {
|
||||
aggContent.WriteString(ck.Response)
|
||||
}
|
||||
}
|
||||
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else { aggContent.WriteString(single.Response) }
|
||||
}
|
||||
if !parsedAny {
|
||||
var single ollamaChatStreamChunk
|
||||
if err := json.Unmarshal(body, &single); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
lastChunk = single
|
||||
if single.Message != nil {
|
||||
if len(single.Message.Thinking) > 0 {
|
||||
raw := strings.TrimSpace(string(single.Message.Thinking))
|
||||
if raw != "" && raw != "null" {
|
||||
reasoningBuilder.WriteString(raw)
|
||||
}
|
||||
}
|
||||
aggContent.WriteString(single.Message.Content)
|
||||
} else {
|
||||
aggContent.WriteString(single.Response)
|
||||
}
|
||||
}
|
||||
|
||||
model := lastChunk.Model
|
||||
if model == "" { model = info.UpstreamModelName }
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" { finishReason = "stop" }
|
||||
model := lastChunk.Model
|
||||
if model == "" {
|
||||
model = info.UpstreamModelName
|
||||
}
|
||||
created := toUnix(lastChunk.CreatedAt)
|
||||
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||
content := aggContent.String()
|
||||
finishReason := lastChunk.DoneReason
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{ {
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
} },
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||
if rc := reasoningBuilder.String(); rc != "" {
|
||||
msg.ReasoningContent = rc
|
||||
}
|
||||
full := dto.OpenAITextResponse{
|
||||
Id: common.GetUUID(),
|
||||
Model: model,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Choices: []dto.OpenAITextResponseChoice{{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
}},
|
||||
Usage: *usage,
|
||||
}
|
||||
out, _ := common.Marshal(full)
|
||||
service.IOCopyBytesGracefully(c, resp, out)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
|
||||
func contentPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
// SiliconFlow requires messages array for FIM requests, even if client doesn't send it
|
||||
if (request.Prefix != nil || request.Suffix != nil) && len(request.Messages) == 0 {
|
||||
// Add an empty user message to satisfy SiliconFlow's requirement
|
||||
request.Messages = []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,4 @@ var ModelList = []string{
|
||||
"deepseek-ai/DeepSeek-V3.1",
|
||||
}
|
||||
|
||||
const ChannelName = "submodel"
|
||||
const ChannelName = "submodel"
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"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"
|
||||
|
||||
186
relay/channel/task/sora/adaptor.go
Normal file
186
relay/channel/task/sora/adaptor.go
Normal file
@@ -0,0 +1,186 @@
|
||||
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
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -52,7 +53,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 +65,97 @@ 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 {
|
||||
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)
|
||||
}
|
||||
if taskErr := validatePrompt(prompts[0]); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
if _, ok := form.Value["model"]; !ok {
|
||||
return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true)
|
||||
}
|
||||
action := constant.TaskActionTextGenerate
|
||||
if _, ok := form.File["input_reference"]; ok {
|
||||
action = constant.TaskActionGenerate
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,9 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
|
||||
return nil, errors.New("field prompt is required")
|
||||
}
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
if len(textRequest.Messages) == 0 {
|
||||
// For FIM (Fill-in-the-middle) requests with prefix/suffix, messages is optional
|
||||
// It will be filled by provider-specific adaptors if needed (e.g., SiliconFlow)。Or it is allowed by model vendor(s) (e.g., DeepSeek)
|
||||
if len(textRequest.Messages) == 0 && textRequest.Prefix == nil && textRequest.Suffix == nil {
|
||||
return nil, errors.New("field messages is required")
|
||||
}
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -362,11 +362,20 @@ 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/") {
|
||||
respBody = originTask.Data
|
||||
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()
|
||||
|
||||
@@ -2,17 +2,34 @@ package operation_setting
|
||||
|
||||
import "one-api/setting/config"
|
||||
|
||||
// 额度展示类型
|
||||
const (
|
||||
QuotaDisplayTypeUSD = "USD"
|
||||
QuotaDisplayTypeCNY = "CNY"
|
||||
QuotaDisplayTypeTokens = "TOKENS"
|
||||
QuotaDisplayTypeCustom = "CUSTOM"
|
||||
)
|
||||
|
||||
type GeneralSetting struct {
|
||||
DocsLink string `json:"docs_link"`
|
||||
PingIntervalEnabled bool `json:"ping_interval_enabled"`
|
||||
PingIntervalSeconds int `json:"ping_interval_seconds"`
|
||||
// 当前站点额度展示类型:USD / CNY / TOKENS
|
||||
QuotaDisplayType string `json:"quota_display_type"`
|
||||
// 自定义货币符号,用于 CUSTOM 展示类型
|
||||
CustomCurrencySymbol string `json:"custom_currency_symbol"`
|
||||
// 自定义货币与美元汇率(1 USD = X Custom)
|
||||
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var generalSetting = GeneralSetting{
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
DocsLink: "https://docs.newapi.pro",
|
||||
PingIntervalEnabled: false,
|
||||
PingIntervalSeconds: 60,
|
||||
QuotaDisplayType: QuotaDisplayTypeUSD,
|
||||
CustomCurrencySymbol: "¤",
|
||||
CustomCurrencyExchangeRate: 1.0,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -23,3 +40,52 @@ func init() {
|
||||
func GetGeneralSetting() *GeneralSetting {
|
||||
return &generalSetting
|
||||
}
|
||||
|
||||
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
|
||||
func IsCurrencyDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
|
||||
}
|
||||
|
||||
// IsCNYDisplay 是否以人民币展示
|
||||
func IsCNYDisplay() bool {
|
||||
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
|
||||
}
|
||||
|
||||
// GetQuotaDisplayType 返回额度展示类型
|
||||
func GetQuotaDisplayType() string {
|
||||
return generalSetting.QuotaDisplayType
|
||||
}
|
||||
|
||||
// GetCurrencySymbol 返回当前展示类型对应符号
|
||||
func GetCurrencySymbol() string {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return "$"
|
||||
case QuotaDisplayTypeCNY:
|
||||
return "¥"
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencySymbol != "" {
|
||||
return generalSetting.CustomCurrencySymbol
|
||||
}
|
||||
return "¤"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 X(TOKENS 不适用)
|
||||
func GetUsdToCurrencyRate(usdToCny float64) float64 {
|
||||
switch generalSetting.QuotaDisplayType {
|
||||
case QuotaDisplayTypeUSD:
|
||||
return 1
|
||||
case QuotaDisplayTypeCNY:
|
||||
return usdToCny
|
||||
case QuotaDisplayTypeCustom:
|
||||
if generalSetting.CustomCurrencyExchangeRate > 0 {
|
||||
return generalSetting.CustomCurrencyExchangeRate
|
||||
}
|
||||
return 1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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 @@
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
<analytics></analytics>
|
||||
<analytics></analytics>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,7 +42,7 @@ const OperationSetting = () => {
|
||||
QuotaPerUnit: 0,
|
||||
USDExchangeRate: 0,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: false,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,7 +91,8 @@ const AccountManagement = ({
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||
React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
@@ -236,7 +237,8 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.github_id) || !status.github_oauth
|
||||
isBound(userState.user?.github_id) ||
|
||||
!status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -394,7 +396,8 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||
isBound(userState.user?.linux_do_id) ||
|
||||
!status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -532,7 +535,9 @@ const AccountManagement = ({
|
||||
? () => {
|
||||
Modal.confirm({
|
||||
title: t('确认解绑 Passkey'),
|
||||
content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
|
||||
content: t(
|
||||
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
|
||||
),
|
||||
okText: t('确认解绑'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
@@ -544,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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user