Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de471d0fa0 | |||
| ea5b152313 | |||
| 75e608bdc8 | |||
| c52ff411a7 | |||
| ff407c4607 | |||
| ae380a5a81 | |||
| 71519301e4 | |||
| 497ed7c39a | |||
| 8be5131c29 | |||
| 6a9fd7611a | |||
| c42757f9af | |||
| c450214d78 | |||
| 73e701e49a | |||
| 537c292801 | |||
| b330253fe2 | |||
| d703ccbb79 | |||
| 697ef3fcca | |||
| 1249eebf46 | |||
| a34c284017 | |||
| 3b2c0a0471 | |||
| 207f98252d | |||
| 113b9c8ecb | |||
| a4c069f88d | |||
| 7caf77db63 | |||
| 6faf989549 | |||
| 719d06ecd6 | |||
| 5464d14973 | |||
| 446e1ce10b | |||
| cd8cdebdcb | |||
| 790d832756 | |||
| 63c83d5abd | |||
| 64326a86d5 | |||
| 5c4ed6206e | |||
| 43f9869246 | |||
| 8eb31a7c82 | |||
| ca47fd18f5 | |||
| 296120641a | |||
| 5d18485e9a | |||
| 60fc1eee31 | |||
| da8cf3eef0 | |||
| 1ddffd236c | |||
| 5eeb3c9f18 | |||
| 2d8bdc1b7c | |||
| 2723bd66ad | |||
| 2c4c2002c6 | |||
| 8cf49db0a8 | |||
| 53a884a6fe | |||
| 60d63f87ff | |||
| 603886d422 | |||
| 3c056b00b1 | |||
| e9d408e718 | |||
| c175a41a2b | |||
| 4e335a9997 | |||
| 6f1f8b60f1 | |||
| a318279d5f | |||
| d1542a65ac | |||
| 26ac8b5dc1 | |||
| a8a96a7e60 | |||
| 45f676514a | |||
| 9e4ab6e7bb | |||
| 45aea22451 | |||
| 5b9aa9e77a | |||
| 80bf73b41c | |||
| e83ec743c8 | |||
| 824c6d9133 | |||
| 76f8112ee7 | |||
| 8afd1d1247 | |||
| b4b58ee887 | |||
| 97ddfeab59 | |||
| 6db071543c | |||
| d83db15801 | |||
| 2780f71b8f | |||
| 052da457eb | |||
| 6f415428d3 | |||
| 59a93cf5c7 | |||
| 867d8acfc3 | |||
| 30d3a3a5f7 | |||
| d2576ddcd3 | |||
| 4ca47ee236 | |||
| 16dd7237c0 | |||
| 1915344838 | |||
| 15ff8e0268 | |||
| a1c82841b5 | |||
| 1e6f31b235 | |||
| 2eaa943d9f | |||
| 7a5348caa3 | |||
| 4dd68bad52 | |||
| 0f043ae404 | |||
| 75c05bb4b8 | |||
| 81d3dc08e5 | |||
| e8c36762fd | |||
| e2dbd02cbb | |||
| c8d3768087 | |||
| 5681c92b3f | |||
| 6e5a359110 | |||
| 77d3157592 | |||
| 39e05118ff | |||
| 9e59ffc3d8 | |||
| abad0d3cc0 | |||
| 979aeceb5c |
@@ -0,0 +1,92 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve tag & write VERSION
|
||||
id: version
|
||||
run: |
|
||||
if echo "${{ github.ref }}" | grep -q "^refs/tags/"; then
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
TAG="dev-${SHORT_SHA}"
|
||||
fi
|
||||
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||
echo "${TAG}" > VERSION
|
||||
echo "Building tag: ${TAG}"
|
||||
cat VERSION
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building image with tag: ${{ env.TAG }}"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.TAG }} \
|
||||
-t git.viaeon.com/admin/new-api:latest .
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.TAG }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.TAG }}
|
||||
echo "Pushing latest..."
|
||||
docker push git.viaeon.com/admin/new-api:latest
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.TAG }} git.viaeon.com/admin/new-api:latest 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
|
||||
- name: Deploy via SSH
|
||||
if: success()
|
||||
run: |
|
||||
if [ -z "${{ secrets.DEPLOY_SSH_HOST }}" ]; then
|
||||
echo "DEPLOY_SSH_HOST not set, skip deploy"
|
||||
exit 0
|
||||
fi
|
||||
apk add --no-cache sshpass 2>/dev/null || apt-get update && apt-get install -y sshpass 2>/dev/null || true
|
||||
sshpass -p "${{ secrets.DEPLOY_SSH_PASS }}" ssh -o StrictHostKeyChecking=no -p ${{ secrets.DEPLOY_SSH_PORT || 22 }} ${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }} "cd ${{ secrets.DEPLOY_DIR || '/opt/new-api' }} && docker compose pull && docker compose up -d"
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Docker Build (alpha)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- alpha
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Alpha Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Determine alpha version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||
echo "$VERSION" > VERSION
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin 2>&1
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building alpha image..."
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.VERSION }} \
|
||||
-t git.viaeon.com/admin/new-api:alpha . 2>&1
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.VERSION }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.VERSION }}
|
||||
echo "Pushing alpha..."
|
||||
docker push git.viaeon.com/admin/new-api:alpha
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.VERSION }} git.viaeon.com/admin/new-api:alpha 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Docker Build (nightly)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- nightly
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Nightly Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Determine nightly version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||
echo "$VERSION" > VERSION
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin 2>&1
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building nightly image..."
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.VERSION }} \
|
||||
-t git.viaeon.com/admin/new-api:nightly . 2>&1
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.VERSION }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.VERSION }}
|
||||
echo "Pushing nightly..."
|
||||
docker push git.viaeon.com/admin/new-api:nightly
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.VERSION }} git.viaeon.com/admin/new-api:nightly 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
@@ -0,0 +1,82 @@
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
name: PR Quality Check
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check PR description
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: admin/new-api
|
||||
GITEA_URL: https://git.viaeon.com
|
||||
run: |
|
||||
# 获取 PR 信息
|
||||
PR_INFO=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
PR_BODY=$(echo "$PR_INFO" | jq -r '.body // empty')
|
||||
PR_TITLE=$(echo "$PR_INFO" | jq -r '.title // empty')
|
||||
PR_USER=$(echo "$PR_INFO" | jq -r '.user.login // empty')
|
||||
|
||||
FAILED=0
|
||||
REASONS=""
|
||||
|
||||
# 检查 PR 描述是否为空
|
||||
if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR description is empty\n"
|
||||
fi
|
||||
|
||||
# 检查 PR 标题是否为空
|
||||
if [ -z "$PR_TITLE" ] || [ "$PR_TITLE" = "null" ]; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR title is empty\n"
|
||||
fi
|
||||
|
||||
# 检查是否包含纯 AI 生成标记
|
||||
if echo "$PR_BODY" | grep -qi "Generated with Claude Code"; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR appears to be purely AI-generated without meaningful human involvement\n"
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -eq 1 ]; then
|
||||
echo "PR check failed:"
|
||||
echo -e "$REASONS"
|
||||
|
||||
# 添加标签
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/labels" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"labels": ["pr-check-failed"]}'
|
||||
|
||||
# 添加评论
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容,我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。"}'
|
||||
|
||||
# 关闭 PR
|
||||
curl -s -X PATCH \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state": "closed"}'
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR check passed!"
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Release (Linux)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!*-alpha*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag name to build (e.g., v0.10.8)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Linux Release
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
export PATH="/toolcache/bin:$PATH"
|
||||
# Install Go
|
||||
if ! command -v go &> /dev/null; then
|
||||
curl -fsSL https://go.dev/dl/go1.25.1.linux-amd64.tar.gz | tar -C /usr/local -xzf -
|
||||
echo "export PATH=\$PATH:/usr/local/go/bin" >> ~/.bashrc
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
fi
|
||||
go version
|
||||
# Install Bun
|
||||
if ! command -v bun &> /dev/null; then
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
fi
|
||||
bun --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine Version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
VERSION=$(git describe --tags 2>/dev/null || echo "$TAG")
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Build Frontend (default)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:/usr/local/go/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:/usr/local/go/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
|
||||
- name: Build Backend (arm64)
|
||||
run: |
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
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 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
|
||||
- name: Generate checksums
|
||||
run: sha256sum new-api-* > checksums-linux.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-build
|
||||
path: |
|
||||
new-api-*
|
||||
checksums-linux.txt
|
||||
|
||||
release:
|
||||
name: Create Gitea Release
|
||||
needs: [build-linux]
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine Version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
run: |
|
||||
# 使用 Gitea API 创建 Release
|
||||
TAG="${{ env.TAG }}"
|
||||
REPO="admin/new-api"
|
||||
GITEA_URL="https://git.viaeon.com"
|
||||
|
||||
# 创建 Release
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG}\",
|
||||
\"name\": \"${TAG}\",
|
||||
\"body\": \"Release ${TAG}\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
|
||||
echo "Created release ID: ${RELEASE_ID}"
|
||||
|
||||
# 上传附件
|
||||
find artifacts -type f | while read file; do
|
||||
echo "Uploading: ${file}"
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
-F "name=$(basename ${file})"
|
||||
done
|
||||
|
||||
echo "Release ${TAG} created successfully!"
|
||||
+2
-1
@@ -7,9 +7,10 @@ upload
|
||||
*.db
|
||||
build
|
||||
*.db-journal
|
||||
logs
|
||||
/logs
|
||||
web/default/dist
|
||||
web/classic/dist
|
||||
web/daisy/dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# ModelsToken 管理平台 - 产品需求文档 (PRD)
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
ModelsToken 是一个 AI API 管理与分发平台,为开发者和企业提供统一的 AI 模型接入、密钥管理、用量计费、渠道代理等一站式服务。新前端将采用 React + DaisyUI 5 + TypeScript 构建,替换现有的 Default/Classic 双前端,同时新增本地文档管理功能。
|
||||
|
||||
- 目标用户:AI 应用开发者、企业运维人员、API 服务管理者
|
||||
- 核心价值:简化 AI API 的管理复杂度,提供直观的操作界面和完整的文档支持
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
### 2.1 用户角色
|
||||
|
||||
| 角色 | 注册方式 | 核心权限 |
|
||||
|------|----------|----------|
|
||||
| 普通用户 | 用户名/邮箱/OAuth | 密钥管理、充值、订阅、日志查看、文档访问 |
|
||||
| 管理员 | 由超级管理员指定 | 渠道管理、用户管理、兑换码、模型管理、订阅管理 |
|
||||
| 超级管理员 | 系统初始化 | 全部权限 + 系统设置 |
|
||||
|
||||
### 2.2 功能模块
|
||||
|
||||
#### 公共页面(无需登录)
|
||||
1. **首页**:Hero 区域、特性展示、快速入门指引
|
||||
2. **登录页**:用户名/密码、OAuth 登录(GitHub/Discord/OIDC/LinuxDO/微信/Telegram/自定义)
|
||||
3. **注册页**:注册表单 + Turnstile 人机验证
|
||||
4. **忘记密码**:邮箱重置链接
|
||||
5. **模型定价**:模型价格列表、搜索筛选
|
||||
6. **关于页面**:项目信息、版本、许可证
|
||||
7. **用户协议/隐私政策**
|
||||
8. **初始化向导**:首次部署配置
|
||||
|
||||
#### 用户功能(需登录)
|
||||
1. **仪表盘**:额度概览、使用趋势图、API 信息面板、公告、FAQ
|
||||
2. **API 密钥管理**:创建/编辑/删除/批量操作、额度限制、模型限制、IP 限制
|
||||
3. **钱包/充值**:余额查看、兑换码充值、在线支付(易支付/Stripe/Creem/Waffo)、签到
|
||||
4. **订阅管理**:查看计划、购买订阅、当前订阅状态
|
||||
5. **使用日志**:请求日志搜索/筛选、MJ 日志、任务日志、统计图表
|
||||
6. **个人设置**:资料编辑、2FA 设置、Passkey 管理、OAuth 绑定、语言切换
|
||||
7. **Playground**:API 在线调试、Chat Completions 测试
|
||||
8. **文档中心**(新增):本地文档管理、分类浏览、搜索、Markdown 渲染
|
||||
|
||||
#### 管理员功能
|
||||
1. **渠道管理**:CRUD、测试、余额更新、标签管理、批量操作、多密钥、Codex OAuth、Ollama 管理
|
||||
2. **用户管理**:列表/搜索/创建/编辑/升降级/启禁/额度调整
|
||||
3. **兑换码管理**:CRUD、批量删除无效码
|
||||
4. **模型管理**:模型元数据 CRUD、上游同步、缺失模型检测
|
||||
5. **供应商管理**:CRUD
|
||||
6. **订阅管理**:计划 CRUD、用户订阅管理
|
||||
7. **部署管理**:io.net 部署 CRUD、容器管理、日志
|
||||
|
||||
#### 超级管理员 - 系统设置
|
||||
1. **站点设置**:名称/Logo/页脚/公告/首页内容/服务器地址
|
||||
2. **认证设置**:注册/登录开关、OAuth 配置、Turnstile、Passkey、自定义 OAuth
|
||||
3. **计费设置**:额度/倍率/支付配置/签到/分组倍率
|
||||
4. **内容设置**:公告/FAQ/Uptime Kuma/聊天/绘图/Midjourney
|
||||
5. **模型设置**:透传/思维模型/Gemini/Claude 配置
|
||||
6. **运维设置**:重试/自动禁用/SMTP/性能监控/日志
|
||||
7. **安全设置**:速率限制/敏感词/SSRF 防护/IP 过滤
|
||||
|
||||
### 2.3 新增功能 - 本地文档管理
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 文档分类 | 支持多级分类树,管理员可创建/编辑/删除分类 |
|
||||
| 文档 CRUD | 管理员创建/编辑/删除文档,支持 Markdown 编辑器 |
|
||||
| 文档浏览 | 用户按分类浏览文档,支持搜索 |
|
||||
| 文档搜索 | 全文搜索文档标题和内容 |
|
||||
| 文档版本 | 文档更新历史记录 |
|
||||
| 权限控制 | 可设置文档为公开/登录可见/管理员可见 |
|
||||
|
||||
## 3. 核心流程
|
||||
|
||||
### 3.1 用户认证流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"访问平台" --> "已登录?"
|
||||
"已登录?" -->|"是"| "仪表盘"
|
||||
"已登录?" -->|"否"| "登录页"
|
||||
"登录页" --> "输入凭证"
|
||||
"输入凭证" --> "需要2FA?"
|
||||
"需要2FA?" -->|"是"| "输入2FA码"
|
||||
"需要2FA?" -->|"否"| "验证成功"
|
||||
"输入2FA码" --> "验证成功"
|
||||
"验证成功" --> "仪表盘"
|
||||
"登录页" --> "OAuth登录"
|
||||
"OAuth登录" --> "OAuth回调"
|
||||
"OAuth回调" --> "已绑定账号?"
|
||||
"已绑定账号?" -->|"是"| "仪表盘"
|
||||
"已绑定账号?" -->|"否"| "绑定/注册"
|
||||
```
|
||||
|
||||
### 3.2 API 调用流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"创建API密钥" --> "配置密钥参数"
|
||||
"配置密钥参数" --> "使用密钥调用API"
|
||||
"使用密钥调用API" --> "平台路由到渠道"
|
||||
"平台路由到渠道" --> "返回结果"
|
||||
"返回结果" --> "记录日志"
|
||||
"记录日志" --> "扣除额度"
|
||||
```
|
||||
|
||||
### 3.3 文档管理流程(新增)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"管理员创建分类" --> "创建文档"
|
||||
"创建文档" --> "Markdown编辑"
|
||||
"Markdown编辑" --> "设置可见性"
|
||||
"设置可见性" --> "发布文档"
|
||||
"发布文档" --> "用户浏览/搜索"
|
||||
```
|
||||
|
||||
## 4. 用户界面设计
|
||||
|
||||
### 4.1 设计风格
|
||||
|
||||
- **主色调**:深蓝 (#1e293b) + 亮蓝 (#3b82f6) 渐变,搭配 DaisyUI 的 `business` 主题
|
||||
- **辅助色**:翡翠绿 (#10b981) 用于成功/在线状态,琥珀色 (#f59e0b) 用于警告
|
||||
- **按钮风格**:DaisyUI 默认圆角按钮,主要操作用 `btn-primary`,危险操作用 `btn-error`
|
||||
- **字体**:JetBrains Mono(代码/密钥)+ Noto Sans SC(中文正文)
|
||||
- **布局风格**:左侧固定导航栏 + 顶部状态栏 + 主内容区,响应式折叠
|
||||
- **图标**:Lucide React 图标库
|
||||
- **动效**:DaisyUI 内置动画 + 页面切换淡入
|
||||
|
||||
### 4.2 页面设计概览
|
||||
|
||||
| 页面 | 模块 | UI 元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | Hero | 渐变背景、特性卡片、快速开始按钮 |
|
||||
| 登录 | 表单 | 居中卡片、OAuth 按钮组、Turnstile |
|
||||
| 仪表盘 | 统计卡片 | 4 列额度卡片、折线图、公告栏、API 信息 |
|
||||
| 密钥管理 | 数据表 | 搜索栏、筛选器、表格、批量操作栏 |
|
||||
| 渠道管理 | 数据表+表单 | 标签筛选、测试按钮、多密钥管理抽屉 |
|
||||
| 系统设置 | 标签页 | 7 大分类侧边导航、表单分组、开关/输入框 |
|
||||
| 文档中心 | 侧边树+内容 | 分类树导航、Markdown 渲染、搜索框、面包屑 |
|
||||
| Playground | 分栏 | 左侧参数面板、右侧响应面板、模型选择器 |
|
||||
|
||||
### 4.3 响应式设计
|
||||
|
||||
- 桌面优先(1280px+)
|
||||
- 平板适配(768px-1279px):侧边栏折叠为抽屉
|
||||
- 移动端适配(<768px):单列布局,表格改为卡片列表
|
||||
|
||||
### 4.4 布局结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 (Navbar) │
|
||||
│ Logo | 搜索 | 通知 | 用户菜单 | 主题切换 │
|
||||
├──────┬───────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧边 │ 主内容区 │
|
||||
│ 导航 │ │
|
||||
│ 栏 │ ┌─────────────────────────────────┐ │
|
||||
│ │ │ 面包屑 + 页面标题 + 操作按钮 │ │
|
||||
│ 仪表盘│ ├─────────────────────────────────┤ │
|
||||
│ 密钥 │ │ │ │
|
||||
│ 渠道 │ │ 页面内容 │ │
|
||||
│ 用户 │ │ │ │
|
||||
│ 日志 │ │ │ │
|
||||
│ 钱包 │ └─────────────────────────────────┘ │
|
||||
│ 订阅 │ │
|
||||
│ 文档 │ │
|
||||
│ 设置 │ │
|
||||
│ │ │
|
||||
└──────┴───────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,459 @@
|
||||
# ModelsToken 管理平台 - 技术架构文档
|
||||
|
||||
## 1. 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "前端 (React + DaisyUI 5)"
|
||||
A["React 18"] --> B["React Router v6"]
|
||||
B --> C["页面组件"]
|
||||
C --> D["DaisyUI 5 组件"]
|
||||
D --> E["Tailwind CSS 4"]
|
||||
A --> F["Zustand 状态管理"]
|
||||
A --> G["React Query 数据请求"]
|
||||
A --> H["i18next 国际化"]
|
||||
A --> I["React Markdown 渲染"]
|
||||
end
|
||||
subgraph "后端 (Go + Gin)"
|
||||
J["Gin HTTP Server"]
|
||||
J --> K["API 路由"]
|
||||
J --> L["Relay 代理"]
|
||||
K --> M["控制器"]
|
||||
M --> N["模型层"]
|
||||
N --> O["数据库 (SQLite/MySQL/PostgreSQL)"]
|
||||
end
|
||||
C -->|"Axios HTTP"| K
|
||||
```
|
||||
|
||||
## 2. 技术说明
|
||||
|
||||
- **前端框架**:React 18 + TypeScript
|
||||
- **UI 库**:DaisyUI 5 + Tailwind CSS 4
|
||||
- **构建工具**:Vite 6
|
||||
- **路由**:React Router v6(懒加载)
|
||||
- **状态管理**:Zustand(轻量级,替代 Redux)
|
||||
- **数据请求**:TanStack React Query v5 + Axios
|
||||
- **国际化**:i18next + react-i18next
|
||||
- **图表**:Recharts
|
||||
- **Markdown**:react-markdown + remark-gfm + rehype-highlight
|
||||
- **图标**:Lucide React
|
||||
- **代码高亮**:highlight.js
|
||||
- **表单验证**:React Hook Form + Zod
|
||||
- **通知**:react-hot-toast
|
||||
- **项目目录**:`web/daisy/`
|
||||
|
||||
## 3. 路由定义
|
||||
|
||||
### 3.1 公共路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/` | 首页 |
|
||||
| `/login` | 登录 |
|
||||
| `/register` | 注册 |
|
||||
| `/forgot-password` | 忘记密码 |
|
||||
| `/reset-password` | 密码重置确认 |
|
||||
| `/setup` | 初始化向导 |
|
||||
| `/pricing` | 模型定价 |
|
||||
| `/about` | 关于 |
|
||||
| `/user-agreement` | 用户协议 |
|
||||
| `/privacy-policy` | 隐私政策 |
|
||||
| `/oauth/callback/:provider` | OAuth 回调 |
|
||||
|
||||
### 3.2 认证后路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/dashboard` | 仪表盘 |
|
||||
| `/tokens` | API 密钥管理 |
|
||||
| `/wallet` | 钱包/充值 |
|
||||
| `/subscriptions` | 订阅管理 |
|
||||
| `/logs` | 使用日志 |
|
||||
| `/logs/midjourney` | MJ 日志 |
|
||||
| `/logs/tasks` | 任务日志 |
|
||||
| `/profile` | 个人设置 |
|
||||
| `/playground` | Playground |
|
||||
| `/docs` | 文档中心(新增) |
|
||||
| `/docs/:slug` | 文档详情(新增) |
|
||||
|
||||
### 3.3 管理员路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/admin/channels` | 渠道管理 |
|
||||
| `/admin/users` | 用户管理 |
|
||||
| `/admin/redemptions` | 兑换码管理 |
|
||||
| `/admin/models` | 模型管理 |
|
||||
| `/admin/vendors` | 供应商管理 |
|
||||
| `/admin/deployments` | 部署管理 |
|
||||
| `/admin/subscriptions` | 订阅计划管理 |
|
||||
|
||||
### 3.4 超级管理员路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/settings/site` | 站点设置 |
|
||||
| `/settings/auth` | 认证设置 |
|
||||
| `/settings/billing` | 计费设置 |
|
||||
| `/settings/content` | 内容设置 |
|
||||
| `/settings/models` | 模型设置 |
|
||||
| `/settings/operations` | 运维设置 |
|
||||
| `/settings/security` | 安全设置 |
|
||||
| `/settings/docs` | 文档管理(新增) |
|
||||
|
||||
## 4. API 定义
|
||||
|
||||
### 4.1 核心类型
|
||||
|
||||
```typescript
|
||||
// 用户
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: number; // 1=user, 10=admin, 100=root
|
||||
status: number;
|
||||
quota: number;
|
||||
used_quota: number;
|
||||
request_count: number;
|
||||
group: string;
|
||||
aff_code: string;
|
||||
inviter_id: number;
|
||||
language: string;
|
||||
access_token: string;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 渠道
|
||||
interface Channel {
|
||||
id: number;
|
||||
type: number;
|
||||
key: string;
|
||||
openai_organization?: string;
|
||||
base_url: string;
|
||||
models: string;
|
||||
model_mapping?: string;
|
||||
group: string;
|
||||
groups: string[];
|
||||
name: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
status: number;
|
||||
tag?: string;
|
||||
setting?: string;
|
||||
test_time: number;
|
||||
response_time: number;
|
||||
balance: number;
|
||||
balance_updated_time: number;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 令牌
|
||||
interface Token {
|
||||
id: number;
|
||||
user_id: number;
|
||||
key: string;
|
||||
status: number;
|
||||
name: string;
|
||||
created_time: number;
|
||||
accessed_time: number;
|
||||
expired_time: number;
|
||||
remain_quota: number;
|
||||
unlimited_quota: boolean;
|
||||
used_quota: number;
|
||||
models: string;
|
||||
subnet: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 日志
|
||||
interface Log {
|
||||
id: number;
|
||||
user_id: number;
|
||||
created_at: number;
|
||||
type: number;
|
||||
content: string;
|
||||
username: string;
|
||||
token_name: string;
|
||||
model_name: string;
|
||||
quota: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
channel_id: number;
|
||||
token_id: number;
|
||||
group: string;
|
||||
request_id: string;
|
||||
ip: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
// 订阅计划
|
||||
interface SubscriptionPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
duration_days: number;
|
||||
quota: number;
|
||||
models: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 文档(新增)
|
||||
interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string; // Markdown
|
||||
category_id: number;
|
||||
category?: DocumentCategory;
|
||||
visibility: 'public' | 'auth' | 'admin';
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
author_id: number;
|
||||
author?: User;
|
||||
versions?: DocumentVersion[];
|
||||
}
|
||||
|
||||
interface DocumentCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
children?: DocumentCategory[];
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface DocumentVersion {
|
||||
id: number;
|
||||
document_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
author_id: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 新增文档管理 API
|
||||
|
||||
| 端点 | 方法 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/api/docs/categories` | GET | 公开 | 获取分类树 |
|
||||
| `/api/docs/categories` | POST | Admin | 创建分类 |
|
||||
| `/api/docs/categories/:id` | PUT | Admin | 更新分类 |
|
||||
| `/api/docs/categories/:id` | DELETE | Admin | 删除分类 |
|
||||
| `/api/docs/` | GET | 按可见性 | 文档列表(支持搜索) |
|
||||
| `/api/docs/:slug` | GET | 按可见性 | 获取文档详情 |
|
||||
| `/api/docs/` | POST | Admin | 创建文档 |
|
||||
| `/api/docs/:id` | PUT | Admin | 更新文档 |
|
||||
| `/api/docs/:id` | DELETE | Admin | 删除文档 |
|
||||
| `/api/docs/:id/versions` | GET | Admin | 文档版本历史 |
|
||||
|
||||
## 5. 项目目录结构
|
||||
|
||||
```
|
||||
web/daisy/
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── public/
|
||||
│ └── manifest.json
|
||||
└── src/
|
||||
├── main.tsx # 入口
|
||||
├── App.tsx # 根组件 + 路由
|
||||
├── vite-env.d.ts
|
||||
├── api/ # API 请求层
|
||||
│ ├── client.ts # Axios 实例 + 拦截器
|
||||
│ ├── auth.ts # 认证 API
|
||||
│ ├── channel.ts # 渠道 API
|
||||
│ ├── token.ts # 令牌 API
|
||||
│ ├── user.ts # 用户 API
|
||||
│ ├── log.ts # 日志 API
|
||||
│ ├── subscription.ts # 订阅 API
|
||||
│ ├── redemption.ts # 兑换码 API
|
||||
│ ├── model.ts # 模型 API
|
||||
│ ├── vendor.ts # 供应商 API
|
||||
│ ├── deployment.ts # 部署 API
|
||||
│ ├── option.ts # 系统设置 API
|
||||
│ ├── payment.ts # 支付 API
|
||||
│ └── doc.ts # 文档 API(新增)
|
||||
├── stores/ # Zustand 状态
|
||||
│ ├── auth.ts # 认证状态
|
||||
│ └── ui.ts # UI 状态(侧边栏/主题)
|
||||
├── hooks/ # 自定义 Hooks
|
||||
│ ├── useAuth.ts
|
||||
│ ├── usePermission.ts
|
||||
│ └── useQuota.ts
|
||||
├── components/ # 通用组件
|
||||
│ ├── layout/
|
||||
│ │ ├── AppLayout.tsx # 主布局
|
||||
│ │ ├── Sidebar.tsx # 侧边导航
|
||||
│ │ ├── Navbar.tsx # 顶部导航
|
||||
│ │ └── Breadcrumb.tsx # 面包屑
|
||||
│ ├── common/
|
||||
│ │ ├── QuotaDisplay.tsx # 额度显示
|
||||
│ │ ├── ModelBadge.tsx # 模型标签
|
||||
│ │ ├── StatusBadge.tsx # 状态标签
|
||||
│ │ ├── SearchInput.tsx # 搜索框
|
||||
│ │ ├── DataTable.tsx # 数据表格
|
||||
│ │ ├── ConfirmDialog.tsx # 确认对话框
|
||||
│ │ └── LoadingSpinner.tsx # 加载动画
|
||||
│ └── charts/
|
||||
│ ├── QuotaChart.tsx # 额度趋势图
|
||||
│ └── StatsChart.tsx # 统计图表
|
||||
├── pages/ # 页面组件
|
||||
│ ├── public/
|
||||
│ │ ├── Home.tsx
|
||||
│ │ ├── Login.tsx
|
||||
│ │ ├── Register.tsx
|
||||
│ │ ├── ForgotPassword.tsx
|
||||
│ │ ├── Pricing.tsx
|
||||
│ │ ├── About.tsx
|
||||
│ │ └── Setup.tsx
|
||||
│ ├── dashboard/
|
||||
│ │ └── Dashboard.tsx
|
||||
│ ├── tokens/
|
||||
│ │ ├── TokenList.tsx
|
||||
│ │ └── TokenForm.tsx
|
||||
│ ├── channels/
|
||||
│ │ ├── ChannelList.tsx
|
||||
│ │ └── ChannelForm.tsx
|
||||
│ ├── users/
|
||||
│ │ ├── UserList.tsx
|
||||
│ │ └── UserForm.tsx
|
||||
│ ├── logs/
|
||||
│ │ ├── LogList.tsx
|
||||
│ │ ├── MidjourneyLog.tsx
|
||||
│ │ └── TaskLog.tsx
|
||||
│ ├── wallet/
|
||||
│ │ └── Wallet.tsx
|
||||
│ ├── subscriptions/
|
||||
│ │ ├── PlanList.tsx
|
||||
│ │ └── MySubscription.tsx
|
||||
│ ├── redemptions/
|
||||
│ │ └── RedemptionList.tsx
|
||||
│ ├── models/
|
||||
│ │ └── ModelList.tsx
|
||||
│ ├── vendors/
|
||||
│ │ └── VendorList.tsx
|
||||
│ ├── deployments/
|
||||
│ │ └── DeploymentList.tsx
|
||||
│ ├── playground/
|
||||
│ │ └── Playground.tsx
|
||||
│ ├── profile/
|
||||
│ │ └── Profile.tsx
|
||||
│ ├── docs/ # 文档中心(新增)
|
||||
│ │ ├── DocCenter.tsx # 文档浏览主页
|
||||
│ │ ├── DocViewer.tsx # 文档阅读页
|
||||
│ │ ├── DocEditor.tsx # 文档编辑页(管理员)
|
||||
│ │ └── DocCategoryManager.tsx # 分类管理(管理员)
|
||||
│ └── settings/
|
||||
│ ├── SiteSettings.tsx
|
||||
│ ├── AuthSettings.tsx
|
||||
│ ├── BillingSettings.tsx
|
||||
│ ├── ContentSettings.tsx
|
||||
│ ├── ModelSettings.tsx
|
||||
│ ├── OperationsSettings.tsx
|
||||
│ ├── SecuritySettings.tsx
|
||||
│ └── DocSettings.tsx # 文档设置(新增)
|
||||
├── i18n/ # 国际化
|
||||
│ ├── index.ts
|
||||
│ └── locales/
|
||||
│ ├── en.json
|
||||
│ └── zh.json
|
||||
├── lib/ # 工具函数
|
||||
│ ├── constants.ts
|
||||
│ ├── utils.ts
|
||||
│ ├── quota.ts
|
||||
│ └── channel-types.ts
|
||||
└── types/ # TypeScript 类型
|
||||
├── api.ts
|
||||
├── channel.ts
|
||||
├── token.ts
|
||||
├── user.ts
|
||||
├── log.ts
|
||||
├── subscription.ts
|
||||
├── doc.ts
|
||||
└── option.ts
|
||||
```
|
||||
|
||||
## 6. 数据模型(新增文档管理)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
"document_categories" {
|
||||
int id PK
|
||||
string name
|
||||
string slug UK
|
||||
int parent_id FK
|
||||
int sort_order
|
||||
timestamp created_at
|
||||
}
|
||||
"documents" {
|
||||
int id PK
|
||||
string title
|
||||
string slug UK
|
||||
text content
|
||||
int category_id FK
|
||||
string visibility
|
||||
int sort_order
|
||||
int author_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
"document_versions" {
|
||||
int id PK
|
||||
int document_id FK
|
||||
text content
|
||||
int author_id FK
|
||||
timestamp created_at
|
||||
}
|
||||
"document_categories" ||--o{ "document_categories" : "parent"
|
||||
"document_categories" ||--o{ "documents" : "has"
|
||||
"documents" ||--o{ "document_versions" : "has"
|
||||
```
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
CREATE TABLE document_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
parent_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
category_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
|
||||
visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'auth', 'admin')),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_slug ON documents(slug);
|
||||
CREATE INDEX idx_documents_category ON documents(category_id);
|
||||
CREATE INDEX idx_documents_visibility ON documents(visibility);
|
||||
CREATE INDEX idx_document_versions_doc ON document_versions(document_id);
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
new-api Notices
|
||||
|
||||
new-api
|
||||
Copyright (c) QuantumNous and contributors.
|
||||
Copyright (c) modelstoken and contributors.
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0.
|
||||
See LICENSE for the full project license terms.
|
||||
@@ -19,7 +19,7 @@ Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project in a prominent about, legal, footer, or
|
||||
attribution location:
|
||||
|
||||
https://github.com/QuantumNous/new-api
|
||||
https://git.viaeon.com/admin/new-api
|
||||
|
||||
Modified versions must not misrepresent the origin of the software and must
|
||||
mark their changes in accordance with AGPLv3 Section 7(c).
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
var StartTime = time.Now().Unix() // unit: second
|
||||
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
|
||||
var SystemName = "New API"
|
||||
var SystemName = "ModelsToken"
|
||||
var Footer = ""
|
||||
var Logo = ""
|
||||
var TopUpLink = ""
|
||||
|
||||
@@ -51,17 +51,21 @@ type themeAwareFileSystem struct {
|
||||
}
|
||||
|
||||
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
|
||||
if GetTheme() == "classic" {
|
||||
switch GetTheme() {
|
||||
case "classic":
|
||||
return t.classicFS.Exists(prefix, path)
|
||||
default:
|
||||
return t.defaultFS.Exists(prefix, path)
|
||||
}
|
||||
return t.defaultFS.Exists(prefix, path)
|
||||
}
|
||||
|
||||
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
|
||||
if GetTheme() == "classic" {
|
||||
switch GetTheme() {
|
||||
case "classic":
|
||||
return t.classicFS.Open(name)
|
||||
default:
|
||||
return t.defaultFS.Open(name)
|
||||
}
|
||||
return t.defaultFS.Open(name)
|
||||
}
|
||||
|
||||
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
|
||||
|
||||
+2
-2
@@ -112,11 +112,11 @@ func InitEnv() {
|
||||
|
||||
// Initialize rate limit variables
|
||||
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
|
||||
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
|
||||
@@ -206,4 +206,8 @@ var ChannelSpecialBases = map[string]ChannelSpecialBase{
|
||||
ClaudeBaseURL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
OpenAIBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
"tencent-coding-plan": {
|
||||
ClaudeBaseURL: "https://api.lkeap.cloud.tencent.com/coding",
|
||||
OpenAIBaseURL: "https://api.lkeap.cloud.tencent.com/coding/v3",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -312,7 +312,11 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
default:
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
|
||||
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCategories 获取文档分类列表(公开)
|
||||
func GetCategories(c *gin.Context) {
|
||||
categories, err := model.GetDocumentCategories()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, categories)
|
||||
}
|
||||
|
||||
// CreateCategory 创建文档分类(管理员)
|
||||
func CreateCategory(c *gin.Context) {
|
||||
var category model.DocumentCategory
|
||||
if err := c.ShouldBindJSON(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if category.Name == "" {
|
||||
common.ApiErrorMsg(c, "分类名称不能为空")
|
||||
return
|
||||
}
|
||||
if category.Slug == "" {
|
||||
common.ApiErrorMsg(c, "分类标识不能为空")
|
||||
return
|
||||
}
|
||||
if err := model.CreateDocumentCategory(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &category)
|
||||
}
|
||||
|
||||
// UpdateCategory 更新文档分类(管理员)
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var category model.DocumentCategory
|
||||
if err := c.ShouldBindJSON(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
category.Id = id
|
||||
if err := model.UpdateDocumentCategory(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &category)
|
||||
}
|
||||
|
||||
// DeleteCategory 删除文档分类(管理员)
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeleteDocumentCategory(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// GetDocuments 获取文档列表(公开,根据认证状态过滤可见性)
|
||||
func GetDocuments(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
categoryIdStr := c.Query("category_id")
|
||||
|
||||
var categoryId *int
|
||||
if categoryIdStr != "" {
|
||||
id, err := strconv.Atoi(categoryIdStr)
|
||||
if err == nil {
|
||||
categoryId = &id
|
||||
}
|
||||
}
|
||||
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
// 根据用户认证状态决定可见性过滤
|
||||
visibility := c.Query("visibility")
|
||||
role := c.GetInt("role")
|
||||
|
||||
var documents []*model.Document
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
if role >= common.RoleAdminUser {
|
||||
// 管理员可看所有
|
||||
documents, total, err = model.GetDocuments(keyword, visibility, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
} else if role >= common.RoleCommonUser {
|
||||
// 普通用户只能看 public 和 auth
|
||||
if visibility == "public" || visibility == "auth" {
|
||||
documents, total, err = model.GetDocuments(keyword, visibility, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
} else {
|
||||
documents, total, err = model.GetDocumentsByVisibility(keyword, []string{"public", "auth"}, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
}
|
||||
} else {
|
||||
// 未登录用户只能看 public
|
||||
documents, total, err = model.GetDocuments(keyword, "public", categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(documents)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetDocument 获取单个文档(根据可见性检查权限)
|
||||
func GetDocument(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
doc, err := model.GetDocumentBySlug(slug)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查可见性权限
|
||||
role := c.GetInt("role")
|
||||
switch doc.Visibility {
|
||||
case "admin":
|
||||
if role < common.RoleAdminUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权访问该文档",
|
||||
})
|
||||
return
|
||||
}
|
||||
case "auth":
|
||||
if role < common.RoleCommonUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请先登录后查看该文档",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, doc)
|
||||
}
|
||||
|
||||
// CreateDocument 创建文档(管理员)
|
||||
func CreateDocument(c *gin.Context) {
|
||||
var doc model.Document
|
||||
if err := c.ShouldBindJSON(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if doc.Title == "" {
|
||||
common.ApiErrorMsg(c, "文档标题不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Slug == "" {
|
||||
common.ApiErrorMsg(c, "文档标识不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Content == "" {
|
||||
common.ApiErrorMsg(c, "文档内容不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Visibility == "" {
|
||||
doc.Visibility = "public"
|
||||
}
|
||||
doc.AuthorId = c.GetInt("id")
|
||||
if err := model.CreateDocument(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &doc)
|
||||
}
|
||||
|
||||
// UpdateDocument 更新文档(管理员,自动创建版本记录)
|
||||
func UpdateDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var doc model.Document
|
||||
if err := c.ShouldBindJSON(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
doc.Id = id
|
||||
|
||||
// 获取旧文档内容,自动创建版本记录
|
||||
oldDoc, err := model.GetDocumentById(id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
version := &model.DocumentVersion{
|
||||
DocumentId: oldDoc.Id,
|
||||
Content: oldDoc.Content,
|
||||
AuthorId: oldDoc.AuthorId,
|
||||
}
|
||||
if err := model.CreateDocumentVersion(version); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.UpdateDocument(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &doc)
|
||||
}
|
||||
|
||||
// DeleteDocument 删除文档(管理员)
|
||||
func DeleteDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeleteDocument(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// GetDocumentVersions 获取文档版本历史(管理员)
|
||||
func GetDocumentVersions(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
versions, total, err := model.GetDocumentVersions(id, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(versions)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
+1
-13
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||
@@ -29,10 +28,6 @@ var completionRatioMetaOptionKeys = []string{
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func isPaymentComplianceOptionKey(key string) bool {
|
||||
return strings.HasPrefix(key, "payment_setting.compliance_")
|
||||
}
|
||||
|
||||
func isPositiveOptionValue(value string) bool {
|
||||
intValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
@@ -139,15 +134,8 @@ func UpdateOption(c *gin.Context) {
|
||||
}
|
||||
switch option.Key {
|
||||
case "QuotaForInviter", "QuotaForInvitee":
|
||||
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
// no compliance check needed
|
||||
default:
|
||||
if isPaymentComplianceOptionKey(option.Key) {
|
||||
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
|
||||
return
|
||||
}
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PaymentComplianceRequest struct {
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
func requirePaymentCompliance(c *gin.Context) bool {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ConfirmPaymentCompliance(c *gin.Context) {
|
||||
if c.GetBool("use_access_token") {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation requires dashboard session authentication. API access token is not allowed.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req PaymentComplianceRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if !req.Confirmed {
|
||||
common.ApiErrorMsg(c, "请确认合规声明")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
userId := c.GetInt("id")
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
updates := map[string]string{
|
||||
"payment_setting.compliance_confirmed": "true",
|
||||
"payment_setting.compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"payment_setting.compliance_confirmed_at": strconv.FormatInt(now, 10),
|
||||
"payment_setting.compliance_confirmed_by": strconv.Itoa(userId),
|
||||
"payment_setting.compliance_confirmed_ip": clientIP,
|
||||
}
|
||||
|
||||
for key, value := range updates {
|
||||
if err := model.UpdateOption(key, value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
|
||||
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
|
||||
userId,
|
||||
clientIP,
|
||||
operation_setting.CurrentComplianceTermsVersion,
|
||||
now,
|
||||
))
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"confirmed": true,
|
||||
"terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"confirmed_at": now,
|
||||
"confirmed_by": userId,
|
||||
})
|
||||
}
|
||||
@@ -7,14 +7,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func isPaymentComplianceConfirmed() bool {
|
||||
return operation_setting.IsPaymentComplianceConfirmed()
|
||||
}
|
||||
|
||||
func isStripeTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripePriceId) != ""
|
||||
@@ -29,9 +22,6 @@ func isStripeWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isCreemTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
products := strings.TrimSpace(setting.CreemProducts)
|
||||
return strings.TrimSpace(setting.CreemApiKey) != "" &&
|
||||
products != "" &&
|
||||
@@ -47,9 +37,6 @@ func isCreemWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isWaffoTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
if !setting.WaffoEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -74,11 +61,6 @@ func isWaffoWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isWaffoPancakeTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
// Presence-of-credentials = enabled. Webhook public keys ship inside
|
||||
// the SDK; mode (test/prod) is read from each event.
|
||||
return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
|
||||
@@ -93,9 +75,6 @@ func isWaffoPancakeWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isEpayTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func confirmPaymentComplianceForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
paymentSetting := operation_setting.GetPaymentSetting()
|
||||
originalConfirmed := paymentSetting.ComplianceConfirmed
|
||||
originalTermsVersion := paymentSetting.ComplianceTermsVersion
|
||||
t.Cleanup(func() {
|
||||
paymentSetting.ComplianceConfirmed = originalConfirmed
|
||||
paymentSetting.ComplianceTermsVersion = originalTermsVersion
|
||||
})
|
||||
paymentSetting.ComplianceConfirmed = true
|
||||
paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion
|
||||
}
|
||||
|
||||
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalAPISecret := setting.StripeApiSecret
|
||||
originalWebhookSecret := setting.StripeWebhookSecret
|
||||
originalPriceID := setting.StripePriceId
|
||||
@@ -45,7 +31,6 @@ func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalAPIKey := setting.CreemApiKey
|
||||
originalProducts := setting.CreemProducts
|
||||
originalWebhookSecret := setting.CreemWebhookSecret
|
||||
@@ -68,7 +53,6 @@ func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalEnabled := setting.WaffoEnabled
|
||||
originalSandbox := setting.WaffoSandbox
|
||||
originalAPIKey := setting.WaffoApiKey
|
||||
@@ -113,7 +97,6 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalMerchantID := setting.WaffoPancakeMerchantID
|
||||
originalPrivateKey := setting.WaffoPancakePrivateKey
|
||||
originalProductID := setting.WaffoPancakeProductID
|
||||
@@ -123,9 +106,6 @@ func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
setting.WaffoPancakeProductID = originalProductID
|
||||
})
|
||||
|
||||
// Presence of all three credentials enables the gateway. Webhook public
|
||||
// keys are bundled in the SDK and there is no separate Enabled toggle —
|
||||
// clear any of the three fields to disable.
|
||||
setting.WaffoPancakeMerchantID = ""
|
||||
setting.WaffoPancakePrivateKey = "private"
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
@@ -143,7 +123,6 @@ func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalPayAddress := operation_setting.PayAddress
|
||||
originalEpayID := operation_setting.EpayId
|
||||
originalEpayKey := operation_setting.EpayKey
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -60,11 +59,6 @@ func GetRedemption(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AddRedemption(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
|
||||
redemption := model.Redemption{}
|
||||
err := c.ShouldBindJSON(&redemption)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -29,11 +28,6 @@ type SubscriptionBalancePayRequest struct {
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiSuccess(c, []SubscriptionPlanDTO{})
|
||||
return
|
||||
}
|
||||
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -98,10 +92,6 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
}
|
||||
|
||||
func SubscriptionRequestBalancePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
var req SubscriptionBalancePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
@@ -139,10 +129,6 @@ type AdminUpsertSubscriptionPlanRequest struct {
|
||||
}
|
||||
|
||||
func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -204,10 +190,6 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
@@ -305,10 +287,6 @@ type AdminUpdateSubscriptionPlanStatusRequest struct {
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
@@ -333,10 +311,6 @@ type AdminBindSubscriptionRequest struct {
|
||||
}
|
||||
|
||||
func AdminBindSubscription(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -376,10 +350,6 @@ type AdminCreateUserSubscriptionRequest struct {
|
||||
|
||||
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
|
||||
func AdminCreateUserSubscription(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
|
||||
@@ -21,10 +21,6 @@ type SubscriptionCreemPayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionCreemPayRequest
|
||||
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
|
||||
@@ -22,10 +22,6 @@ type SubscriptionEpayPayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestEpay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
|
||||
@@ -21,10 +21,6 @@ type SubscriptionStripePayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
|
||||
@@ -21,10 +21,6 @@ type SubscriptionWaffoPancakePayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionWaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
|
||||
+2
-8
@@ -22,13 +22,8 @@ import (
|
||||
)
|
||||
|
||||
func GetTopUpInfo(c *gin.Context) {
|
||||
complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed()
|
||||
|
||||
// 获取支付方式
|
||||
payMethods := operation_setting.PayMethods
|
||||
if !complianceConfirmed {
|
||||
payMethods = []map[string]string{}
|
||||
}
|
||||
|
||||
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||
if isStripeTopUpEnabled() {
|
||||
@@ -101,9 +96,8 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"enable_redemption": complianceConfirmed,
|
||||
"payment_compliance_confirmed": complianceConfirmed,
|
||||
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"enable_redemption": true,
|
||||
"payment_compliance_confirmed": true,
|
||||
"waffo_pay_methods": func() interface{} {
|
||||
if enableWaffo {
|
||||
return setting.GetWaffoPayMethods()
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
|
||||
@@ -344,10 +343,6 @@ type TransferAffQuotaRequest struct {
|
||||
}
|
||||
|
||||
func TransferAffQuota(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, true)
|
||||
if err != nil {
|
||||
@@ -1104,11 +1099,6 @@ func getTopUpLock(userID int) *topUpTryLock {
|
||||
}
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
lock := getTopUpLock(id)
|
||||
if !lock.TryLock() {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ version: '3.4' # For compatibility with older Docker versions
|
||||
|
||||
services:
|
||||
new-api:
|
||||
image: calciumion/new-api:latest
|
||||
image: git.viaeon.com/admin/new-api:latest
|
||||
container_name: new-api
|
||||
restart: always
|
||||
command: --log-dir /app/logs
|
||||
|
||||
+6
-6
@@ -26,11 +26,11 @@ type ImageRequest struct {
|
||||
OutputFormat json.RawMessage `json:"output_format,omitempty"`
|
||||
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
|
||||
PartialImages json.RawMessage `json:"partial_images,omitempty"`
|
||||
// Stream bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
// zhipu 4v
|
||||
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
|
||||
UserId json.RawMessage `json:"user_id,omitempty"`
|
||||
@@ -163,7 +163,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
func (i *ImageRequest) IsStream(c *gin.Context) bool {
|
||||
return false
|
||||
return i.Stream != nil && *i.Stream
|
||||
}
|
||||
|
||||
func (i *ImageRequest) SetModelName(modelName string) {
|
||||
|
||||
Vendored
+2
-2
@@ -18,10 +18,10 @@
|
||||
"openai",
|
||||
"claude"
|
||||
],
|
||||
"author": "QuantumNous",
|
||||
"author": "modelstoken",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/QuantumNous/new-api"
|
||||
"url": "https://git.viaeon.com/admin/new-api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -152,7 +152,6 @@ const (
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
MsgPaymentComplianceRequired = "payment.compliance_required"
|
||||
)
|
||||
|
||||
// Topup related messages
|
||||
|
||||
@@ -164,7 +164,7 @@ func main() {
|
||||
common.SysLog(fmt.Sprintf("panic detected: %v", err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://git.viaeon.com/admin/new-api/issues", err),
|
||||
"type": "new_api_panic",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -163,6 +163,10 @@ func TryUserAuth() func(c *gin.Context) {
|
||||
if id != nil {
|
||||
c.Set("id", id)
|
||||
}
|
||||
role := session.Get("role")
|
||||
if role != nil {
|
||||
c.Set("role", role)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func RelayPanicRecover() gin.HandlerFunc {
|
||||
common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack())))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://git.viaeon.com/admin/new-api/issues", err),
|
||||
"type": "new_api_panic",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
CategoryId *int `json:"category_id" gorm:"index"`
|
||||
Visibility string `json:"visibility" gorm:"default:'public'"` // public, auth, admin
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
AuthorId int `json:"author_id" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func GetDocuments(keyword string, visibility string, categoryId *int, startIdx int, num int) ([]*Document, int64, error) {
|
||||
query := DB.Model(&Document{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("title LIKE ? OR content LIKE ?", like, like)
|
||||
}
|
||||
if visibility != "" {
|
||||
query = query.Where("visibility = ?", visibility)
|
||||
}
|
||||
if categoryId != nil {
|
||||
query = query.Where("category_id = ?", *categoryId)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var documents []*Document
|
||||
if err := query.Order("sort_order ASC, id DESC").Offset(startIdx).Limit(num).Find(&documents).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return documents, total, nil
|
||||
}
|
||||
|
||||
func GetDocumentsByVisibility(keyword string, visibilities []string, categoryId *int, startIdx int, num int) ([]*Document, int64, error) {
|
||||
query := DB.Model(&Document{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("title LIKE ? OR content LIKE ?", like, like)
|
||||
}
|
||||
if len(visibilities) > 0 {
|
||||
query = query.Where("visibility IN ?", visibilities)
|
||||
}
|
||||
if categoryId != nil {
|
||||
query = query.Where("category_id = ?", *categoryId)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var documents []*Document
|
||||
if err := query.Order("sort_order ASC, id DESC").Offset(startIdx).Limit(num).Find(&documents).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return documents, total, nil
|
||||
}
|
||||
|
||||
func GetDocumentBySlug(slug string) (*Document, error) {
|
||||
var doc Document
|
||||
err := DB.Where("slug = ?", slug).First(&doc).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func GetDocumentById(id int) (*Document, error) {
|
||||
var doc Document
|
||||
err := DB.First(&doc, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func CreateDocument(doc *Document) error {
|
||||
return DB.Create(doc).Error
|
||||
}
|
||||
|
||||
func UpdateDocument(doc *Document) error {
|
||||
return DB.Model(doc).Select("title", "slug", "content", "category_id", "visibility", "sort_order").Updates(doc).Error
|
||||
}
|
||||
|
||||
func DeleteDocument(id int) error {
|
||||
// Delete associated versions first
|
||||
DB.Where("document_id = ?", id).Delete(&DocumentVersion{})
|
||||
return DB.Delete(&Document{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentCategory struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ParentId *int `json:"parent_id" gorm:"index"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func GetDocumentCategories() ([]*DocumentCategory, error) {
|
||||
var categories []*DocumentCategory
|
||||
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func GetDocumentCategoryTree() ([]*DocumentCategory, error) {
|
||||
var categories []*DocumentCategory
|
||||
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func CreateDocumentCategory(category *DocumentCategory) error {
|
||||
return DB.Create(category).Error
|
||||
}
|
||||
|
||||
func UpdateDocumentCategory(category *DocumentCategory) error {
|
||||
return DB.Model(category).Select("name", "slug", "parent_id", "sort_order").Updates(category).Error
|
||||
}
|
||||
|
||||
func DeleteDocumentCategory(id int) error {
|
||||
return DB.Delete(&DocumentCategory{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentVersion struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
DocumentId int `json:"document_id" gorm:"index;not null"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
AuthorId int `json:"author_id" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func GetDocumentVersions(documentId int, startIdx int, num int) ([]*DocumentVersion, int64, error) {
|
||||
query := DB.Model(&DocumentVersion{}).Where("document_id = ?", documentId)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var versions []*DocumentVersion
|
||||
if err := query.Order("id DESC").Offset(startIdx).Limit(num).Find(&versions).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return versions, total, nil
|
||||
}
|
||||
|
||||
func CreateDocumentVersion(version *DocumentVersion) error {
|
||||
return DB.Create(version).Error
|
||||
}
|
||||
@@ -281,6 +281,9 @@ func migrateDB() error {
|
||||
&CustomOAuthProvider{},
|
||||
&UserOAuthBinding{},
|
||||
&PerfMetric{},
|
||||
&DocumentCategory{},
|
||||
&Document{},
|
||||
&DocumentVersion{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -330,6 +333,9 @@ func migrateDBFast() error {
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
{&PerfMetric{}, "PerfMetric"},
|
||||
{&DocumentCategory{}, "DocumentCategory"},
|
||||
{&Document{}, "Document"},
|
||||
{&DocumentVersion{}, "DocumentVersion"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
+2
-3
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"gorm.io/gorm"
|
||||
@@ -418,7 +417,7 @@ func (user *User) Insert(inviterId int) error {
|
||||
if common.QuotaForNewUser > 0 {
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
|
||||
}
|
||||
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
|
||||
if inviterId != 0 {
|
||||
if common.QuotaForInvitee > 0 {
|
||||
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
|
||||
@@ -479,7 +478,7 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
|
||||
if common.QuotaForNewUser > 0 {
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
|
||||
}
|
||||
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
|
||||
if inviterId != 0 {
|
||||
if common.QuotaForInvitee > 0 {
|
||||
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
channelconstant "github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
@@ -79,9 +81,23 @@ 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.Temperature != nil && isTemperatureOneOnlyModel(getUpstreamModelName(info, request.Model)) && *request.Temperature != 1.0 {
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func getUpstreamModelName(info *relaycommon.RelayInfo, fallback string) string {
|
||||
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
|
||||
return info.UpstreamModelName
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isTemperatureOneOnlyModel(model string) bool {
|
||||
return strings.EqualFold(model, "kimi-k2.6")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package moonshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestKimiK26UsesOnlyAllowedTemperature(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.6",
|
||||
Temperature: common.GetPointer[float64](0.7),
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.6",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, convertedRequest.Temperature)
|
||||
require.Equal(t, 1.0, *convertedRequest.Temperature)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestKimiK26KeepsOmittedTemperatureOmitted(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.6",
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.6",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Nil(t, convertedRequest.Temperature)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestOtherMoonshotModelKeepsTemperature(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.5",
|
||||
Temperature: common.GetPointer[float64](0.7),
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.5",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, convertedRequest.Temperature)
|
||||
require.Equal(t, 0.7, *convertedRequest.Temperature)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -163,6 +164,20 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
|
||||
return url, nil
|
||||
default:
|
||||
// Handle coding plan special base URLs
|
||||
if specialPlan, ok := constant.ChannelSpecialBases[info.ChannelBaseUrl]; ok && specialPlan.OpenAIBaseURL != "" {
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
return fmt.Sprintf("%s/v1/messages", specialPlan.ClaudeBaseURL), nil
|
||||
}
|
||||
switch info.RelayMode {
|
||||
case relayconstant.RelayModeEmbeddings:
|
||||
return fmt.Sprintf("%s/embeddings", specialPlan.OpenAIBaseURL), nil
|
||||
case relayconstant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
|
||||
default:
|
||||
return fmt.Sprintf("%s/chat/completions", specialPlan.OpenAIBaseURL), nil
|
||||
}
|
||||
}
|
||||
if (info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini) &&
|
||||
info.RelayMode != relayconstant.RelayModeResponses &&
|
||||
info.RelayMode != relayconstant.RelayModeResponsesCompact {
|
||||
@@ -220,7 +235,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *
|
||||
header.Set("HTTP-Referer", "https://www.newapi.ai")
|
||||
}
|
||||
if header.Get("X-OpenRouter-Title") == "" {
|
||||
header.Set("X-OpenRouter-Title", "New API")
|
||||
header.Set("X-OpenRouter-Title", "ModelsToken")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -439,10 +454,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
// 使用已解析的 multipart 表单,避免重复解析
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return nil, errors.New("failed to parse multipart form")
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
|
||||
}
|
||||
mf = c.Request.MultipartForm
|
||||
c.Request.MultipartForm = form
|
||||
c.Request.PostForm = url.Values(form.Value)
|
||||
mf = form
|
||||
}
|
||||
|
||||
// 写入所有非文件字段
|
||||
@@ -625,7 +643,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
case relayconstant.RelayModeAudioTranscription:
|
||||
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
|
||||
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
|
||||
usage, err = OpenaiHandlerWithUsage(c, info, resp)
|
||||
if info.IsStream {
|
||||
usage, err = OpenaiImageStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = OpenaiImageHandler(c, info, resp)
|
||||
}
|
||||
case relayconstant.RelayModeRerank:
|
||||
usage, err = common_handler.RerankHandler(c, info, resp)
|
||||
case relayconstant.RelayModeResponses:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConvertImageEditRequestMultipart verifies that ConvertImageRequest
|
||||
// re-serializes multipart image edit requests with all fields (including
|
||||
// stream) and the file intact, both when the form was already parsed and when
|
||||
// it must be re-parsed from the reusable body.
|
||||
func TestConvertImageEditRequestMultipart(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newMultipartContext := func(t *testing.T, prompt string) *gin.Context {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", prompt))
|
||||
require.NoError(t, writer.WriteField("stream", "true"))
|
||||
require.NoError(t, writer.WriteField("partial_images", "3"))
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fake image"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c
|
||||
}
|
||||
|
||||
convertAndReplay := func(t *testing.T, c *gin.Context, prompt string) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesEdits,
|
||||
}
|
||||
request := dto.ImageRequest{
|
||||
Model: "gpt-image-1",
|
||||
Prompt: prompt,
|
||||
Stream: common.GetPointer(true),
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
|
||||
require.NoError(t, err)
|
||||
convertedBody, ok := converted.(*bytes.Buffer)
|
||||
require.True(t, ok)
|
||||
|
||||
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
|
||||
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
|
||||
|
||||
require.Equal(t, "gpt-image-1", replayedRequest.PostForm.Get("model"))
|
||||
require.Equal(t, prompt, replayedRequest.PostForm.Get("prompt"))
|
||||
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
|
||||
require.Equal(t, "3", replayedRequest.PostForm.Get("partial_images"))
|
||||
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
|
||||
|
||||
file, err := replayedRequest.MultipartForm.File["image"][0].Open()
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
fileBytes, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("fake image"), fileBytes)
|
||||
}
|
||||
|
||||
t.Run("with pre-parsed form", func(t *testing.T) {
|
||||
prompt := "edit this image"
|
||||
c := newMultipartContext(t, prompt)
|
||||
require.NoError(t, c.Request.ParseMultipartForm(32<<20))
|
||||
|
||||
convertAndReplay(t, c, prompt)
|
||||
})
|
||||
|
||||
t.Run("re-parses reusable body when form is missing", func(t *testing.T) {
|
||||
prompt := "edit without pre-parsed form"
|
||||
c := newMultipartContext(t, prompt)
|
||||
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
c.Request.MultipartForm = nil
|
||||
c.Request.PostForm = nil
|
||||
|
||||
convertAndReplay(t, c, prompt)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newImageTestContext(t *testing.T, body, contentType string, isStream bool) (*gin.Context, *httptest.ResponseRecorder, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
IsStream: isStream,
|
||||
}
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerForwardsSSEAndUsage covers the core SSE path:
|
||||
// chunks are forwarded with rebuilt event lines, usage is extracted and
|
||||
// normalized (input_tokens -> prompt_tokens with details), and [DONE] is
|
||||
// re-emitted to the client.
|
||||
func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`event: image_generation.partial_image`,
|
||||
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
|
||||
``,
|
||||
`data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`,
|
||||
``,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, usage.PromptTokens)
|
||||
require.Equal(t, 4, usage.CompletionTokens)
|
||||
require.Equal(t, 7, usage.TotalTokens)
|
||||
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
|
||||
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.partial_image`)
|
||||
require.Contains(t, recorder.Body.String(), `data: {"type":"image_generation.partial_image","b64_json":"partial"}`)
|
||||
require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerWrapsJSONResponse covers the non-SSE fallback:
|
||||
// a JSON upstream response is wrapped into pseudo-SSE completed events.
|
||||
func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"created":1710000000,"data":[{"b64_json":"final","revised_prompt":"draw a cat"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, usage.PromptTokens)
|
||||
require.Equal(t, 4, usage.CompletionTokens)
|
||||
require.Equal(t, 7, usage.TotalTokens)
|
||||
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
|
||||
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
require.Empty(t, recorder.Header().Get("Content-Length"))
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
|
||||
require.Contains(t, recorder.Body.String(), `"type":"image_generation.completed"`)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"final"`)
|
||||
require.Contains(t, recorder.Body.String(), `"revised_prompt":"draw a cat"`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
}
|
||||
|
||||
// TestOpenaiImageHandlersReturnJSONError covers JSON error responses for both
|
||||
// entry points: the non-streaming handler and the stream handler's non-SSE
|
||||
// fallback. Neither must leak the error body to the client.
|
||||
func TestOpenaiImageHandlersReturnJSONError(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"error":{"message":"content moderation failed","type":"upstream_error","code":"content_moderation_failed","status":502}}`
|
||||
|
||||
t.Run("non-streaming handler", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", false)
|
||||
|
||||
usage, err := OpenaiImageHandler(c, info, resp)
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, http.StatusOK, err.StatusCode)
|
||||
oaiError := err.ToOpenAIError()
|
||||
require.Equal(t, "content moderation failed", oaiError.Message)
|
||||
require.Equal(t, "upstream_error", oaiError.Type)
|
||||
require.Equal(t, "content_moderation_failed", oaiError.Code)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
})
|
||||
|
||||
t.Run("stream handler JSON fallback", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, http.StatusOK, err.StatusCode)
|
||||
require.Equal(t, "content moderation failed", err.ToOpenAIError().Message)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent verifies that an error
|
||||
// event inside the SSE stream is recorded as a soft error while the payload is
|
||||
// still forwarded to the client.
|
||||
func TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`event: image_generation.partial_image`,
|
||||
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
|
||||
``,
|
||||
`event: error`,
|
||||
`data: {"type":"upstream_error","error":{"message":"stream error: stream ID 77; INTERNAL_ERROR; received from peer"}}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
require.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
|
||||
require.True(t, info.StreamStatus.HasErrors())
|
||||
require.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
require.Contains(t, info.StreamStatus.Errors[0].Message, "INTERNAL_ERROR")
|
||||
// The scanner strips the upstream "event: error" line; the event name is
|
||||
// rebuilt from the JSON "type" field (upstream_error). The error message
|
||||
// is still forwarded in the data: payload (stream ID 77).
|
||||
require.Contains(t, recorder.Body.String(), `event: upstream_error`)
|
||||
require.Contains(t, recorder.Body.String(), `stream ID 77`)
|
||||
}
|
||||
@@ -14,12 +14,9 @@ import (
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
|
||||
@@ -293,421 +290,3 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
|
||||
return &simpleResponse.Usage, nil
|
||||
}
|
||||
|
||||
func streamTTSResponse(c *gin.Context, resp *http.Response) {
|
||||
c.Writer.WriteHeaderNow()
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
logger.LogWarn(c, "streaming not supported")
|
||||
_, err := io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
logger.LogWarn(c, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buffer)
|
||||
//logger.LogInfo(c, fmt.Sprintf("streamTTSResponse read %d bytes", n))
|
||||
if n > 0 {
|
||||
if _, writeErr := c.Writer.Write(buffer[:n]); writeErr != nil {
|
||||
logger.LogError(c, writeErr.Error())
|
||||
break
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
|
||||
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
|
||||
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
|
||||
info.IsStream = true
|
||||
clientConn := info.ClientWs
|
||||
targetConn := info.TargetWs
|
||||
|
||||
clientClosed := make(chan struct{})
|
||||
targetClosed := make(chan struct{})
|
||||
sendChan := make(chan []byte, 100)
|
||||
receiveChan := make(chan []byte, 100)
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
usage := &dto.RealtimeUsage{}
|
||||
localUsage := &dto.RealtimeUsage{}
|
||||
sumUsage := &dto.RealtimeUsage{}
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in client reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from client: %v", err)
|
||||
}
|
||||
close(clientClosed)
|
||||
return
|
||||
}
|
||||
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
|
||||
if realtimeEvent.Session != nil {
|
||||
if realtimeEvent.Session.Tools != nil {
|
||||
info.RealtimeTools = realtimeEvent.Session.Tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
|
||||
err = helper.WssString(c, targetConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in target reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := targetConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from target: %v", err)
|
||||
}
|
||||
close(targetClosed)
|
||||
return
|
||||
}
|
||||
info.SetFirstResponseTime()
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
|
||||
realtimeUsage := realtimeEvent.Response.Usage
|
||||
if realtimeUsage != nil {
|
||||
usage.TotalTokens += realtimeUsage.TotalTokens
|
||||
usage.InputTokens += realtimeUsage.InputTokens
|
||||
usage.OutputTokens += realtimeUsage.OutputTokens
|
||||
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
|
||||
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
|
||||
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
|
||||
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
|
||||
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
|
||||
err := preConsumeUsage(c, info, usage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
usage = &dto.RealtimeUsage{}
|
||||
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
info.IsFirstRequest = false
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
err = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
// print now usage
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
|
||||
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
|
||||
realtimeSession := realtimeEvent.Session
|
||||
if realtimeSession != nil {
|
||||
// update audio format
|
||||
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
|
||||
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
|
||||
}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.OutputTokens += textToken + audioToken
|
||||
localUsage.OutputTokenDetails.TextTokens += textToken
|
||||
localUsage.OutputTokenDetails.AudioTokens += audioToken
|
||||
}
|
||||
|
||||
err = helper.WssString(c, clientConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to client: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case receiveChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-clientClosed:
|
||||
case <-targetClosed:
|
||||
case err := <-errChan:
|
||||
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
|
||||
logger.LogError(c, "realtime error: "+err.Error())
|
||||
case <-c.Done():
|
||||
}
|
||||
|
||||
if usage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, usage, sumUsage)
|
||||
}
|
||||
|
||||
if localUsage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
}
|
||||
|
||||
// check usage total tokens, if 0, use local usage
|
||||
|
||||
return nil, sumUsage
|
||||
}
|
||||
|
||||
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
|
||||
if usage == nil || totalUsage == nil {
|
||||
return fmt.Errorf("invalid usage pointer")
|
||||
}
|
||||
|
||||
totalUsage.TotalTokens += usage.TotalTokens
|
||||
totalUsage.InputTokens += usage.InputTokens
|
||||
totalUsage.OutputTokens += usage.OutputTokens
|
||||
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
|
||||
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
|
||||
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
|
||||
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
|
||||
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
|
||||
// clear usage
|
||||
err := service.PreWssConsumeQuota(ctx, info, usage)
|
||||
return err
|
||||
}
|
||||
|
||||
func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
err = common.Unmarshal(responseBody, &usageResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
// Once we've written to the client, we should not return errors anymore
|
||||
// because the upstream has already consumed resources and returned content
|
||||
// We should still perform billing even if parsing fails
|
||||
// format
|
||||
if usageResp.InputTokens > 0 {
|
||||
usageResp.PromptTokens += usageResp.InputTokens
|
||||
}
|
||||
if usageResp.OutputTokens > 0 {
|
||||
usageResp.CompletionTokens += usageResp.OutputTokens
|
||||
}
|
||||
if usageResp.InputTokensDetails != nil {
|
||||
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:
|
||||
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
|
||||
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
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeMoonshot:
|
||||
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeOpenAI:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := common.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
|
||||
}
|
||||
|
||||
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
|
||||
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
|
||||
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Usage struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// 遍历choices查找cached_tokens
|
||||
for _, choice := range payload.Choices {
|
||||
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
|
||||
return *choice.Usage.CachedTokens, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
|
||||
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Timings struct {
|
||||
CachedTokens *int `json:"cache_n"`
|
||||
} `json:"timings"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Timings.CachedTokens == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *payload.Timings.CachedTokens, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenaiImageHandler handles non-streaming OpenAI image responses
|
||||
// (generations/edits), returning the parsed usage for billing.
|
||||
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
err = common.Unmarshal(responseBody, &usageResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
// normalizeOpenAIUsage maps the OpenAI Images usage shape (input_tokens /
|
||||
// output_tokens / input_tokens_details) onto the canonical prompt/completion
|
||||
// fields. It is used only on the OpenAI image relay paths (generations/edits,
|
||||
// streaming and non-streaming): the image API never returns prompt_tokens /
|
||||
// completion_tokens, so the overwrite (=) semantics here are equivalent to the
|
||||
// previous additive (+=) behavior while avoiding any future double-counting if
|
||||
// both field sets are ever populated. Do not reuse this on chat/embedding paths
|
||||
// without revisiting the overwrite semantics.
|
||||
func normalizeOpenAIUsage(usage *dto.Usage) {
|
||||
if usage == nil {
|
||||
return
|
||||
}
|
||||
if usage.InputTokens != 0 {
|
||||
usage.PromptTokens = usage.InputTokens
|
||||
}
|
||||
if usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = usage.OutputTokens
|
||||
}
|
||||
if usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
}
|
||||
|
||||
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
logger.LogError(c, "invalid image stream response")
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return OpenaiImageHandler(c, info, resp)
|
||||
}
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
return OpenaiImageJSONAsStreamHandler(c, info, resp)
|
||||
}
|
||||
// Reuse the shared streaming engine (helper.StreamScannerHandler) so the
|
||||
// image streaming path gets the same ping keepalive, streaming-timeout
|
||||
// watchdog, client-disconnect detection, panic recovery and goroutine
|
||||
// cleanup as every other relay stream. The scanner delivers only the
|
||||
// "data:" payload, so the SSE "event:" line is rebuilt from the JSON "type"
|
||||
// field (real OpenAI image events keep event == type).
|
||||
usage := &dto.Usage{}
|
||||
var lastStreamData []byte
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
raw := common.StringToByteSlice(data)
|
||||
lastStreamData = raw
|
||||
if isOpenAIImageStreamErrorEvent(raw) {
|
||||
// Record the error as a soft error; the scanner drives the final
|
||||
// EndReason. HasErrors() flags the failure for logging/handling.
|
||||
sr.Error(fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw)))
|
||||
}
|
||||
var usageResp dto.SimpleResponse
|
||||
if err := common.Unmarshal(raw, &usageResp); err == nil {
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
usage = &usageResp.Usage
|
||||
}
|
||||
}
|
||||
writeOpenaiImageStreamChunk(c, raw)
|
||||
})
|
||||
|
||||
// StreamScannerHandler consumes the upstream [DONE]; re-emit it so the
|
||||
// client still receives a terminal data: [DONE].
|
||||
if info != nil && info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
|
||||
helper.Done(c)
|
||||
}
|
||||
|
||||
applyUsagePostProcessing(info, usage, lastStreamData)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
|
||||
// it emits an "event:" line derived from the JSON "type" field (when present)
|
||||
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
|
||||
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) {
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = common.Unmarshal(data, &payload)
|
||||
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", eventName)})
|
||||
}
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
|
||||
_ = helper.FlushWriter(c)
|
||||
}
|
||||
|
||||
// isOpenAIImageStreamErrorEvent detects upstream error chunks by JSON content
|
||||
// only ("type" of error/upstream_error, or a non-empty "error" field). The SSE
|
||||
// "event:" line is not available here: StreamScannerHandler delivers only the
|
||||
// "data:" payload. A payload carrying just a "message" key is deliberately NOT
|
||||
// treated as an error to avoid false positives.
|
||||
func isOpenAIImageStreamErrorEvent(data []byte) bool {
|
||||
if !json.Valid(data) {
|
||||
return false
|
||||
}
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := common.Unmarshal(data, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
payloadType := strings.ToLower(strings.TrimSpace(payload.Type))
|
||||
return payloadType == "error" || payloadType == "upstream_error" || len(payload.Error) > 0
|
||||
}
|
||||
|
||||
func extractOpenAIImageStreamErrorMessage(data []byte) string {
|
||||
if len(data) == 0 || !json.Valid(data) {
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
var payload struct {
|
||||
Message string `json:"message"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := common.Unmarshal(data, &payload); err != nil {
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
if msg := strings.TrimSpace(payload.Message); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if len(payload.Error) > 0 {
|
||||
var nested struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := common.Unmarshal(payload.Error, &nested); err == nil {
|
||||
if msg := strings.TrimSpace(nested.Message); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
if msg := strings.TrimSpace(common.JsonRawMessageToString(payload.Error)); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
|
||||
func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var imageResp dto.ImageResponse
|
||||
if err := common.Unmarshal(responseBody, &imageResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
_ = common.Unmarshal(responseBody, &usageResp)
|
||||
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
created := imageResp.Created
|
||||
if created == 0 {
|
||||
created = time.Now().Unix()
|
||||
}
|
||||
if info != nil {
|
||||
info.SetFirstResponseTime()
|
||||
}
|
||||
for _, image := range imageResp.Data {
|
||||
payload := map[string]any{
|
||||
"type": "image_generation.completed",
|
||||
"created_at": created,
|
||||
}
|
||||
if image.Url != "" {
|
||||
payload["url"] = image.Url
|
||||
}
|
||||
if image.B64Json != "" {
|
||||
payload["b64_json"] = image.B64Json
|
||||
}
|
||||
if image.RevisedPrompt != "" {
|
||||
payload["revised_prompt"] = image.RevisedPrompt
|
||||
}
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
payload["usage"] = usageResp.Usage
|
||||
}
|
||||
if err := writeOpenaiImageStreamPayload(c, "image_generation.completed", payload); err != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
}
|
||||
if err := writeOpenaiImageStreamDone(c); err != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
if info != nil {
|
||||
info.ReceivedResponseCount += len(imageResp.Data)
|
||||
if info.StreamStatus == nil {
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
}
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamPayload(c *gin.Context, eventName string, payload any) error {
|
||||
data, err := common.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if eventName != "" {
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", eventName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", data); err != nil {
|
||||
return err
|
||||
}
|
||||
return helper.FlushWriter(c)
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamDone(c *gin.Context) error {
|
||||
if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return helper.FlushWriter(c)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
|
||||
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
|
||||
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
|
||||
info.IsStream = true
|
||||
clientConn := info.ClientWs
|
||||
targetConn := info.TargetWs
|
||||
|
||||
clientClosed := make(chan struct{})
|
||||
targetClosed := make(chan struct{})
|
||||
sendChan := make(chan []byte, 100)
|
||||
receiveChan := make(chan []byte, 100)
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
usage := &dto.RealtimeUsage{}
|
||||
localUsage := &dto.RealtimeUsage{}
|
||||
sumUsage := &dto.RealtimeUsage{}
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in client reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from client: %v", err)
|
||||
}
|
||||
close(clientClosed)
|
||||
return
|
||||
}
|
||||
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
|
||||
if realtimeEvent.Session != nil {
|
||||
if realtimeEvent.Session.Tools != nil {
|
||||
info.RealtimeTools = realtimeEvent.Session.Tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
|
||||
err = helper.WssString(c, targetConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in target reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := targetConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from target: %v", err)
|
||||
}
|
||||
close(targetClosed)
|
||||
return
|
||||
}
|
||||
info.SetFirstResponseTime()
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
|
||||
realtimeUsage := realtimeEvent.Response.Usage
|
||||
if realtimeUsage != nil {
|
||||
usage.TotalTokens += realtimeUsage.TotalTokens
|
||||
usage.InputTokens += realtimeUsage.InputTokens
|
||||
usage.OutputTokens += realtimeUsage.OutputTokens
|
||||
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
|
||||
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
|
||||
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
|
||||
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
|
||||
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
|
||||
err := preConsumeUsage(c, info, usage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
usage = &dto.RealtimeUsage{}
|
||||
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
info.IsFirstRequest = false
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
err = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
// print now usage
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
|
||||
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
|
||||
realtimeSession := realtimeEvent.Session
|
||||
if realtimeSession != nil {
|
||||
// update audio format
|
||||
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
|
||||
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
|
||||
}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.OutputTokens += textToken + audioToken
|
||||
localUsage.OutputTokenDetails.TextTokens += textToken
|
||||
localUsage.OutputTokenDetails.AudioTokens += audioToken
|
||||
}
|
||||
|
||||
err = helper.WssString(c, clientConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to client: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case receiveChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-clientClosed:
|
||||
case <-targetClosed:
|
||||
case err := <-errChan:
|
||||
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
|
||||
logger.LogError(c, "realtime error: "+err.Error())
|
||||
case <-c.Done():
|
||||
}
|
||||
|
||||
if usage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, usage, sumUsage)
|
||||
}
|
||||
|
||||
if localUsage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
}
|
||||
|
||||
// check usage total tokens, if 0, use local usage
|
||||
|
||||
return nil, sumUsage
|
||||
}
|
||||
|
||||
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
|
||||
if usage == nil || totalUsage == nil {
|
||||
return fmt.Errorf("invalid usage pointer")
|
||||
}
|
||||
|
||||
totalUsage.TotalTokens += usage.TotalTokens
|
||||
totalUsage.InputTokens += usage.InputTokens
|
||||
totalUsage.OutputTokens += usage.OutputTokens
|
||||
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
|
||||
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
|
||||
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
|
||||
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
|
||||
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
|
||||
// clear usage
|
||||
err := service.PreWssConsumeQuota(ctx, info, usage)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
)
|
||||
|
||||
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:
|
||||
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
|
||||
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
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeMoonshot:
|
||||
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeOpenAI:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := common.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
|
||||
}
|
||||
|
||||
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
|
||||
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
|
||||
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Usage struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// 遍历choices查找cached_tokens
|
||||
for _, choice := range payload.Choices {
|
||||
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
|
||||
return *choice.Usage.CachedTokens, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
|
||||
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Timings struct {
|
||||
CachedTokens *int `json:"cache_n"`
|
||||
} `json:"timings"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Timings.CachedTokens == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *payload.Timings.CachedTokens, true
|
||||
}
|
||||
@@ -114,7 +114,7 @@ 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) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
|
||||
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp)
|
||||
usage, err = openai.OpenaiImageHandler(c, info, resp)
|
||||
case constant.RelayModeResponses:
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiResponsesStreamHandler(c, info, resp)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetAndValidOpenAIImageRequestMultipartStream verifies multipart image
|
||||
// edit parsing: the stream field is parsed and validated, and the request body
|
||||
// stays replayable for the upstream request.
|
||||
func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newContext := func(t *testing.T, streamValue string, withImage bool) (*gin.Context, string) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", "edit this image"))
|
||||
require.NoError(t, writer.WriteField("stream", streamValue))
|
||||
if withImage {
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fake image"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
originalBody := body.String()
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c, originalBody
|
||||
}
|
||||
|
||||
t.Run("valid stream value keeps body replayable", func(t *testing.T) {
|
||||
c, originalBody := newContext(t, "true", true)
|
||||
|
||||
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.Stream)
|
||||
require.True(t, *req.Stream)
|
||||
require.True(t, req.IsStream(c))
|
||||
|
||||
bodyAfterValidation, err := io.ReadAll(c.Request.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalBody, string(bodyAfterValidation))
|
||||
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
|
||||
require.Len(t, form.File["image"], 1)
|
||||
})
|
||||
|
||||
t.Run("invalid stream value is rejected", func(t *testing.T) {
|
||||
c, _ := newContext(t, "notabool", false)
|
||||
|
||||
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid stream value")
|
||||
})
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
DefaultMaxScannerBufferSize = 64 << 20 // 64MB (64*1024*1024) default SSE buffer size
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
DefaultMaxScannerBufferSize = 128 << 20 // 64MB (64*1024*1024) default SSE buffer size
|
||||
DefaultPingInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
|
||||
assert.NotNil(t, info.StreamStatus)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
|
||||
func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(5)
|
||||
@@ -643,7 +643,7 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -144,16 +146,25 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeImagesEdits:
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
_, err := c.MultipartForm()
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
formData := c.Request.PostForm
|
||||
formData := url.Values(form.Value)
|
||||
c.Request.MultipartForm = form
|
||||
c.Request.PostForm = formData
|
||||
imageRequest.Prompt = formData.Get("prompt")
|
||||
imageRequest.Model = formData.Get("model")
|
||||
imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n"))))
|
||||
imageRequest.Quality = formData.Get("quality")
|
||||
imageRequest.Size = formData.Get("size")
|
||||
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
|
||||
stream, err := strconv.ParseBool(streamValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid stream value: %w", err)
|
||||
}
|
||||
imageRequest.Stream = common.GetPointer(stream)
|
||||
}
|
||||
if imageValue := formData.Get("image"); imageValue != "" {
|
||||
imageRequest.Image, _ = common.Marshal(imageValue)
|
||||
}
|
||||
|
||||
+24
-1
@@ -186,7 +186,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
|
||||
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
|
||||
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
|
||||
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
|
||||
@@ -347,6 +346,30 @@ func SetApiRouter(router *gin.Engine) {
|
||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||
}
|
||||
|
||||
// Document routes (public)
|
||||
docsPublic := apiRouter.Group("/docs")
|
||||
docsPublic.Use(middleware.TryUserAuth())
|
||||
{
|
||||
docsPublic.GET("/categories", controller.GetCategories)
|
||||
docsPublic.GET("/", controller.GetDocuments)
|
||||
docsPublic.GET("/:slug", controller.GetDocument)
|
||||
}
|
||||
|
||||
// Document routes (admin) - use /admin/docs to avoid conflict with /:slug
|
||||
docsAdmin := apiRouter.Group("/admin/docs")
|
||||
docsAdmin.Use(middleware.AdminAuth())
|
||||
{
|
||||
docsAdmin.GET("/categories", controller.GetCategories)
|
||||
docsAdmin.POST("/categories", controller.CreateCategory)
|
||||
docsAdmin.PUT("/categories/:id", controller.UpdateCategory)
|
||||
docsAdmin.DELETE("/categories/:id", controller.DeleteCategory)
|
||||
docsAdmin.GET("/", controller.GetDocuments)
|
||||
docsAdmin.POST("/", controller.CreateDocument)
|
||||
docsAdmin.PUT("/:id", controller.UpdateDocument)
|
||||
docsAdmin.DELETE("/:id", controller.DeleteDocument)
|
||||
docsAdmin.GET("/:id/versions", controller.GetDocumentVersions)
|
||||
}
|
||||
|
||||
vendorRoute := apiRouter.Group("/vendors")
|
||||
vendorRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ThemeAssets holds the embedded frontend assets for both themes.
|
||||
// ThemeAssets holds the embedded frontend assets for all themes.
|
||||
type ThemeAssets struct {
|
||||
DefaultBuildFS embed.FS
|
||||
DefaultIndexPage []byte
|
||||
@@ -37,9 +37,10 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
if common.GetTheme() == "classic" {
|
||||
switch common.GetTheme() {
|
||||
case "classic":
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
||||
} else {
|
||||
default:
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,16 +5,8 @@ import "github.com/QuantumNous/new-api/setting/config"
|
||||
type PaymentSetting struct {
|
||||
AmountOptions []int `json:"amount_options"`
|
||||
AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠
|
||||
|
||||
ComplianceConfirmed bool `json:"compliance_confirmed"`
|
||||
ComplianceTermsVersion string `json:"compliance_terms_version"`
|
||||
ComplianceConfirmedAt int64 `json:"compliance_confirmed_at"`
|
||||
ComplianceConfirmedBy int `json:"compliance_confirmed_by"`
|
||||
ComplianceConfirmedIP string `json:"compliance_confirmed_ip"`
|
||||
}
|
||||
|
||||
const CurrentComplianceTermsVersion = "v1"
|
||||
|
||||
// 默认配置
|
||||
var paymentSetting = PaymentSetting{
|
||||
AmountOptions: []int{10, 20, 50, 100, 200, 500},
|
||||
@@ -29,8 +21,3 @@ func init() {
|
||||
func GetPaymentSetting() *PaymentSetting {
|
||||
return &paymentSetting
|
||||
}
|
||||
|
||||
func IsPaymentComplianceConfirmed() bool {
|
||||
return paymentSetting.ComplianceConfirmed &&
|
||||
paymentSetting.ComplianceTermsVersion == CurrentComplianceTermsVersion
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -16,7 +16,7 @@
|
||||
content="A unified AI model hub for aggregation & distribution. It supports cross-converting various LLMs into OpenAI-compatible, Claude-compatible, or Gemini-compatible formats. A centralized gateway for personal and enterprise model management."
|
||||
/>
|
||||
<meta name="generator" content="new-api" />
|
||||
<title>New API</title>
|
||||
<title>ModelsToken</title>
|
||||
<!--umami-->
|
||||
<!--Google Analytics-->
|
||||
</head>
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 52 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 1.9 MiB |
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useRef } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
+7
-114
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
@@ -56,14 +56,14 @@ const FooterBar = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 gap-8 w-full'>
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('关于我们')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/project-introduction/'
|
||||
href='https://modelstoken.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
@@ -71,21 +71,13 @@ const FooterBar = () => {
|
||||
{t('关于项目')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/support/community-interaction/'
|
||||
href='https://modelstoken.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('联系我们')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/features-introduction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('功能特性')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +87,7 @@ const FooterBar = () => {
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/getting-started/'
|
||||
href='https://modelstoken.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
@@ -103,15 +95,7 @@ const FooterBar = () => {
|
||||
{t('快速开始')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/installation/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('安装指南')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/api/'
|
||||
href='https://modelstoken.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
@@ -120,70 +104,6 @@ const FooterBar = () => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('相关项目')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/novicezk/midjourney-proxy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-key-tool'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
new-api-key-tool
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('友情链接')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-horizon'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
new-api-horizon
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/coaidev/coai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
CoAI
|
||||
</a>
|
||||
<a
|
||||
href='https://www.gpt-load.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
GPT-Load
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -194,20 +114,6 @@ const FooterBar = () => {
|
||||
© {currentYear} {systemName}. {t('版权所有')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className='text-sm'>
|
||||
<span className='!text-semi-color-text-1'>
|
||||
{t('设计与开发由')}{' '}
|
||||
</span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
),
|
||||
@@ -227,19 +133,6 @@ const FooterBar = () => {
|
||||
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
<div className='text-sm flex-shrink-0'>
|
||||
<span className='!text-semi-color-text-1'>
|
||||
{t('设计与开发由')}{' '}
|
||||
</span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
) : (
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import HeaderBar from './headerbar';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
Copyright (C) 2025 modelstoken
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user