mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 00:15:28 +08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958bd124cc | ||
|
|
b89e154e43 | ||
|
|
01924f4a69 | ||
|
|
3725694bdf | ||
|
|
21b12f583a | ||
|
|
d97b86e0ee | ||
|
|
0869ea56cd | ||
|
|
4768440627 | ||
|
|
9f91da403f | ||
|
|
89e5ad24b9 | ||
|
|
3f106ac112 | ||
|
|
f6f6a651fd | ||
|
|
37b867c7ad | ||
|
|
25ea28a277 | ||
|
|
0ac49ab32b | ||
|
|
70c59eb71d | ||
|
|
f60a3ea501 | ||
|
|
3f09d60cdc | ||
|
|
d3b5493d2e | ||
|
|
255feb2e65 | ||
|
|
4b73315df0 | ||
|
|
a086e0cfa1 | ||
|
|
f3bc022a36 | ||
|
|
b7cb7ef0c1 | ||
|
|
267420a46a | ||
|
|
3c66ab958a | ||
|
|
cf2f79b6f4 | ||
|
|
ab6e817c8e | ||
|
|
9ae4630a3b | ||
|
|
d1b8537cfb | ||
|
|
d32b4481da | ||
|
|
52a04ac575 | ||
|
|
0d3d535c08 | ||
|
|
224462018a | ||
|
|
35e89230fd | ||
|
|
9a57af6092 | ||
|
|
2e1bd8a481 | ||
|
|
1e678ecc1a | ||
|
|
962700f525 | ||
|
|
e143d13ff6 | ||
|
|
2f853d7364 | ||
|
|
36099a4ada | ||
|
|
73bdb55cee | ||
|
|
3f3198c959 | ||
|
|
6b8f7f8821 | ||
|
|
ac9a1ae742 | ||
|
|
bd4c2bacbc | ||
|
|
6cfc7051c4 | ||
|
|
22a2a97a76 |
@@ -10,7 +10,9 @@ __pycache__
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
dist/*
|
||||
!dist/docker-input/
|
||||
!dist/docker-input/*.tar.gz
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
40
.github/PULL_REQUEST_TEMPLATE.md
vendored
40
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,20 +1,20 @@
|
||||
#### 💻 变更类型 | Change Type
|
||||
|
||||
<!-- For change type, change [ ] to [x]. -->
|
||||
|
||||
- [ ] ✨ feat
|
||||
- [ ] 🐛 fix
|
||||
- [ ] ♻️ refactor
|
||||
- [ ] 💄 style
|
||||
- [ ] 👷 build
|
||||
- [ ] ⚡️ perf
|
||||
- [ ] 📝 docs
|
||||
- [ ] 🔨 chore
|
||||
|
||||
#### 🔀 变更说明 | Description of Change
|
||||
|
||||
|
||||
|
||||
#### 📝 补充信息 | Additional Information
|
||||
|
||||
|
||||
#### 💻 变更类型 | Change Type
|
||||
|
||||
<!-- For change type, change [ ] to [x]. -->
|
||||
|
||||
- [ ] ✨ feat
|
||||
- [ ] 🐛 fix
|
||||
- [ ] ♻️ refactor
|
||||
- [ ] 💄 style
|
||||
- [ ] 👷 build
|
||||
- [ ] ⚡️ perf
|
||||
- [ ] 📝 docs
|
||||
- [ ] 🔨 chore
|
||||
|
||||
#### 🔀 变更说明 | Description of Change
|
||||
|
||||
<!-- Thank you for your Pull Request. Please provide a description above. -->
|
||||
|
||||
#### 📝 补充信息 | Additional Information
|
||||
|
||||
<!-- Add any other context about the Pull Request here. -->
|
||||
90
.github/workflows/release-artifacts.yml
vendored
90
.github/workflows/release-artifacts.yml
vendored
@@ -4,6 +4,12 @@ on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag to build/publish (e.g. v2.1.6)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -13,8 +19,7 @@ jobs:
|
||||
build-and-upload:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -45,7 +50,7 @@ jobs:
|
||||
- name: Build Multi-Platform Archives
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
mkdir -p dist
|
||||
|
||||
targets=(
|
||||
@@ -82,25 +87,44 @@ jobs:
|
||||
rm -rf "${STAGE}"
|
||||
done
|
||||
|
||||
- name: Prepare Docker release inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${RELEASE_TAG}"
|
||||
mkdir -p dist/docker-input
|
||||
cp "dist/ds2api_${TAG}_linux_amd64.tar.gz" "dist/docker-input/linux_amd64.tar.gz"
|
||||
cp "dist/ds2api_${TAG}_linux_arm64.tar.gz" "dist/docker-input/linux_arm64.tar.gz"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Wait for GHCR endpoint
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for i in {1..6}; do
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 https://ghcr.io/v2/ || true)"
|
||||
if [ "${code}" = "200" ] || [ "${code}" = "401" ] || [ "${code}" = "405" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep "$((i * 10))"
|
||||
done
|
||||
echo "GHCR endpoint is unreachable after multiple retries (last status: ${code:-unknown})." >&2
|
||||
exit 1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: "${{ env.DOCKERHUB_USERNAME != '' }}"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to GHCR (with retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for i in {1..6}; do
|
||||
if echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin; then
|
||||
exit 0
|
||||
fi
|
||||
sleep "$((i * 10))"
|
||||
done
|
||||
echo "Failed to login to GHCR after multiple retries." >&2
|
||||
exit 1
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta_release
|
||||
@@ -108,16 +132,19 @@ jobs:
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ env.DOCKERHUB_USERNAME || 'cjackhwang' }}/ds2api
|
||||
tags: |
|
||||
type=raw,value=${{ github.event.release.tag_name }}
|
||||
type=raw,value=${{ env.RELEASE_TAG }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: runtime-from-dist
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta_release.outputs.tags }}
|
||||
@@ -126,15 +153,17 @@ jobs:
|
||||
- name: Export Docker image archives for release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--target runtime-from-dist \
|
||||
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_amd64.tar" \
|
||||
.
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--target runtime-from-dist \
|
||||
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_arm64.tar" \
|
||||
.
|
||||
|
||||
@@ -146,10 +175,29 @@ jobs:
|
||||
set -euo pipefail
|
||||
(cd dist && sha256sum *.tar.gz *.zip > sha256sums.txt)
|
||||
|
||||
- name: Validate release tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${RELEASE_TAG}"
|
||||
if [ -z "${TAG}" ]; then
|
||||
echo "release tag is empty; set release_tag when using workflow_dispatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${RELEASE_TAG}"
|
||||
FILES=(
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/sha256sums.txt
|
||||
)
|
||||
|
||||
if gh release view "${TAG}" >/dev/null 2>&1; then
|
||||
gh release upload "${TAG}" "${FILES[@]}" --clobber
|
||||
else
|
||||
gh release create "${TAG}" "${FILES[@]}" --title "${TAG}" --notes ""
|
||||
fi
|
||||
|
||||
127
.github/workflows/release-dockerhub.yml
vendored
Normal file
127
.github/workflows/release-dockerhub.yml
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
name: Release to Docker Hub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: '版本类型'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
TAG_VERSION=${LATEST_TAG#v}
|
||||
|
||||
if [ -f VERSION ]; then
|
||||
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
else
|
||||
FILE_VERSION="0.0.0"
|
||||
fi
|
||||
|
||||
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
|
||||
|
||||
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
|
||||
VERSION="$FILE_VERSION"
|
||||
else
|
||||
VERSION="$TAG_VERSION"
|
||||
fi
|
||||
|
||||
echo "Current version: $VERSION"
|
||||
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Calculate next version
|
||||
id: next_version
|
||||
env:
|
||||
VERSION_TYPE: ${{ github.event.inputs.version_type }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.current_version }}"
|
||||
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
|
||||
|
||||
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
|
||||
MAJOR="${version_parts[0]:-0}"
|
||||
MINOR="${version_parts[1]:-0}"
|
||||
PATCH="${version_parts[2]:-0}"
|
||||
|
||||
case "$VERSION_TYPE" in
|
||||
major)
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
;;
|
||||
minor)
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
;;
|
||||
*)
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update VERSION file
|
||||
run: |
|
||||
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
|
||||
|
||||
- name: Commit VERSION and create tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add VERSION
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
|
||||
fi
|
||||
|
||||
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
|
||||
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
|
||||
git push origin HEAD:main "$NEW_TAG"
|
||||
|
||||
# Docker 构建并推送到 Docker Hub
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:${{ steps.next_version.outputs.new_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/ds2api:latest
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
128
.github/workflows/release.yml
vendored
Normal file
128
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
name: Release to Aliyun CR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: '版本类型'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
TAG_VERSION=${LATEST_TAG#v}
|
||||
|
||||
if [ -f VERSION ]; then
|
||||
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
else
|
||||
FILE_VERSION="0.0.0"
|
||||
fi
|
||||
|
||||
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
|
||||
|
||||
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
|
||||
VERSION="$FILE_VERSION"
|
||||
else
|
||||
VERSION="$TAG_VERSION"
|
||||
fi
|
||||
|
||||
echo "Current version: $VERSION"
|
||||
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Calculate next version
|
||||
id: next_version
|
||||
env:
|
||||
VERSION_TYPE: ${{ github.event.inputs.version_type }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.current_version }}"
|
||||
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
|
||||
|
||||
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
|
||||
MAJOR="${version_parts[0]:-0}"
|
||||
MINOR="${version_parts[1]:-0}"
|
||||
PATCH="${version_parts[2]:-0}"
|
||||
|
||||
case "$VERSION_TYPE" in
|
||||
major)
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
;;
|
||||
minor)
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
;;
|
||||
*)
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update VERSION file
|
||||
run: |
|
||||
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
|
||||
|
||||
- name: Commit VERSION and create tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add VERSION
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
|
||||
fi
|
||||
|
||||
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
|
||||
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
|
||||
git push origin HEAD:main "$NEW_TAG"
|
||||
|
||||
# Docker 构建并推送到阿里云
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Aliyun Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.ALIYUN_REGISTRY }}
|
||||
username: ${{ secrets.ALIYUN_REGISTRY_USER }}
|
||||
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }}
|
||||
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }}
|
||||
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
40
.gitignore
vendored
40
.gitignore
vendored
@@ -2,37 +2,6 @@
|
||||
config.json
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -44,7 +13,6 @@ env/
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
uvicorn.log
|
||||
artifacts/
|
||||
|
||||
# Vercel
|
||||
@@ -56,8 +24,6 @@ webui/node_modules/
|
||||
webui/dist/
|
||||
.npm
|
||||
.pnpm-store/
|
||||
# 保留 webui/package-lock.json 用于 CI 缓存
|
||||
# package-lock.json # 如果有根目录的可以忽略
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -86,7 +52,9 @@ coverage*.out
|
||||
cover/
|
||||
|
||||
# Misc
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git/
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
34
DEPLOY.en.md
34
DEPLOY.en.md
@@ -135,11 +135,12 @@ docker-compose up -d --build
|
||||
|
||||
### 2.3 Docker Architecture
|
||||
|
||||
The `Dockerfile` uses a three-stage build:
|
||||
The `Dockerfile` now provides two image paths:
|
||||
|
||||
1. **WebUI build stage**: `node:20` image, runs `npm ci && npm run build`
|
||||
2. **Go build stage**: `golang:1.24` image, compiles the binary
|
||||
3. **Runtime stage**: `debian:bookworm-slim` minimal image
|
||||
1. **Default local/dev path (`runtime-from-source`)**: a three-stage build (WebUI build + Go build + runtime).
|
||||
2. **Release path (`runtime-from-dist`)**: CI first creates `dist/ds2api_<tag>_linux_<arch>.tar.gz`, then Docker directly reuses the binary and `static/admin` assets from those release archives, without running `npm build`/`go build` again.
|
||||
|
||||
The release path keeps Docker images aligned with release archives and reduces duplicate build work.
|
||||
|
||||
Container entry command: `/usr/local/bin/ds2api`, default exposed port: `5001`.
|
||||
|
||||
@@ -160,7 +161,7 @@ Docker Compose includes a built-in health check:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
|
||||
test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -174,6 +175,18 @@ If container logs look normal but the admin panel is unreachable, check these fi
|
||||
1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`).
|
||||
2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`.
|
||||
|
||||
### 2.7 Zeabur One-Click (Dockerfile)
|
||||
|
||||
This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur:
|
||||
|
||||
[](https://zeabur.com/templates/L4CFHP)
|
||||
|
||||
Notes:
|
||||
|
||||
- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`.
|
||||
- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path.
|
||||
- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login).
|
||||
|
||||
---
|
||||
|
||||
## 3. Vercel Deployment
|
||||
@@ -341,6 +354,7 @@ Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
|
||||
|
||||
- **Trigger**: only on Release `published` (no build on normal push)
|
||||
- **Outputs**: multi-platform binary archives + `sha256sums.txt`
|
||||
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
||||
|
||||
| Platform | Architecture | Format |
|
||||
| --- | --- | --- |
|
||||
@@ -378,6 +392,16 @@ cp config.example.json config.json
|
||||
2. Wait for the `Release Artifacts` workflow to complete
|
||||
3. Download the matching archive from Release Assets
|
||||
|
||||
### Pull from GHCR (Optional)
|
||||
|
||||
```bash
|
||||
# latest
|
||||
docker pull ghcr.io/cjackhwang/ds2api:latest
|
||||
|
||||
# specific version (example)
|
||||
docker pull ghcr.io/cjackhwang/ds2api:v2.1.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Reverse Proxy (Nginx)
|
||||
|
||||
34
DEPLOY.md
34
DEPLOY.md
@@ -135,11 +135,12 @@ docker-compose up -d --build
|
||||
|
||||
### 2.3 Docker 架构说明
|
||||
|
||||
`Dockerfile` 使用三阶段构建:
|
||||
`Dockerfile` 提供两条构建路径:
|
||||
|
||||
1. **WebUI 构建阶段**:`node:20` 镜像,执行 `npm ci && npm run build`
|
||||
2. **Go 构建阶段**:`golang:1.24` 镜像,编译二进制文件
|
||||
3. **运行阶段**:`debian:bookworm-slim` 精简镜像
|
||||
1. **本地/开发默认路径(`runtime-from-source`)**:三阶段构建(WebUI 构建 + Go 构建 + 运行阶段)。
|
||||
2. **Release 路径(`runtime-from-dist`)**:CI 先生成 `dist/ds2api_<tag>_linux_<arch>.tar.gz`,再由 Docker 直接复用该发布包内的二进制和 `static/admin` 产物组装运行镜像,不再重复执行 `npm build`/`go build`。
|
||||
|
||||
Release 路径可确保 Docker 镜像与 release 压缩包使用同一套产物,减少重复构建带来的差异。
|
||||
|
||||
容器内启动命令:`/usr/local/bin/ds2api`,默认暴露端口 `5001`。
|
||||
|
||||
@@ -160,7 +161,7 @@ Docker Compose 已配置内置健康检查:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
|
||||
test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -174,6 +175,18 @@ healthcheck:
|
||||
1. **端口是否一致**:`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。
|
||||
2. **开发 compose 的 WebUI 静态文件**:`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js;若仓库里没有 `static/admin`,`/admin` 会返回 404。可先在宿主机构建一次:`./scripts/build-webui.sh`。
|
||||
|
||||
### 2.7 Zeabur 一键部署(Dockerfile)
|
||||
|
||||
仓库提供 `zeabur.yaml` 模板,可在 Zeabur 上一键部署:
|
||||
|
||||
[](https://zeabur.com/templates/L4CFHP)
|
||||
|
||||
部署要点:
|
||||
|
||||
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。
|
||||
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
|
||||
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
|
||||
|
||||
---
|
||||
|
||||
## 三、Vercel 部署
|
||||
@@ -341,6 +354,7 @@ No Output Directory named "public" found after the Build completed.
|
||||
|
||||
- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建)
|
||||
- **构建产物**:多平台二进制压缩包 + `sha256sums.txt`
|
||||
- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
||||
|
||||
| 平台 | 架构 | 文件格式 |
|
||||
| --- | --- | --- |
|
||||
@@ -378,6 +392,16 @@ cp config.example.json config.json
|
||||
2. 等待 Actions 工作流 `Release Artifacts` 完成
|
||||
3. 在 Release 的 Assets 下载对应平台压缩包
|
||||
|
||||
### 拉取 GHCR 镜像(可选)
|
||||
|
||||
```bash
|
||||
# latest
|
||||
docker pull ghcr.io/cjackhwang/ds2api:latest
|
||||
|
||||
# 指定版本(示例)
|
||||
docker pull ghcr.io/cjackhwang/ds2api:v2.1.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、反向代理(Nginx)
|
||||
|
||||
49
Dockerfile
49
Dockerfile
@@ -8,19 +8,54 @@ RUN npm run build
|
||||
|
||||
FROM golang:1.24 AS go-builder
|
||||
WORKDIR /app
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/ds2api ./cmd/ds2api
|
||||
RUN set -eux; \
|
||||
GOOS="${TARGETOS:-$(go env GOOS)}"; \
|
||||
GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \
|
||||
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM busybox:1.36.1-musl AS busybox-tools
|
||||
|
||||
FROM debian:bookworm-slim AS runtime-base
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
|
||||
EXPOSE 5001
|
||||
CMD ["/usr/local/bin/ds2api"]
|
||||
|
||||
FROM runtime-base AS runtime-from-source
|
||||
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
|
||||
COPY --from=go-builder /app/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
|
||||
COPY --from=go-builder /app/config.example.json /app/config.example.json
|
||||
COPY --from=webui-builder /app/static/admin /app/static/admin
|
||||
EXPOSE 5001
|
||||
CMD ["/usr/local/bin/ds2api"]
|
||||
|
||||
FROM busybox-tools AS dist-extract
|
||||
ARG TARGETARCH
|
||||
COPY dist/docker-input/linux_amd64.tar.gz /tmp/ds2api_linux_amd64.tar.gz
|
||||
COPY dist/docker-input/linux_arm64.tar.gz /tmp/ds2api_linux_arm64.tar.gz
|
||||
RUN set -eux; \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) ARCHIVE="/tmp/ds2api_linux_amd64.tar.gz" ;; \
|
||||
arm64) ARCHIVE="/tmp/ds2api_linux_arm64.tar.gz" ;; \
|
||||
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
tar -xzf "${ARCHIVE}" -C /tmp; \
|
||||
PKG_DIR="$(find /tmp -maxdepth 1 -type d -name "ds2api_*_linux_${TARGETARCH}" | head -n1)"; \
|
||||
test -n "${PKG_DIR}"; \
|
||||
mkdir -p /out/static; \
|
||||
cp "${PKG_DIR}/ds2api" /out/ds2api; \
|
||||
cp "${PKG_DIR}/sha3_wasm_bg.7b9ca65ddd.wasm" /out/sha3_wasm_bg.7b9ca65ddd.wasm; \
|
||||
cp "${PKG_DIR}/config.example.json" /out/config.example.json; \
|
||||
cp -R "${PKG_DIR}/static/admin" /out/static/admin
|
||||
|
||||
FROM runtime-base AS runtime-from-dist
|
||||
COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api
|
||||
COPY --from=dist-extract /out/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
|
||||
COPY --from=dist-extract /out/config.example.json /app/config.example.json
|
||||
COPY --from=dist-extract /out/static/admin /app/static/admin
|
||||
|
||||
FROM runtime-from-source AS final
|
||||
|
||||
12
README.MD
12
README.MD
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
|
||||
</p>
|
||||
|
||||
# DS2API
|
||||
|
||||
[](LICENSE)
|
||||
@@ -5,6 +9,7 @@
|
||||

|
||||
[](https://github.com/CJackHwang/ds2api/releases)
|
||||
[](DEPLOY.md)
|
||||
[](https://zeabur.com/templates/L4CFHP)
|
||||
|
||||
语言 / Language: [中文](README.MD) | [English](README.en.md)
|
||||
|
||||
@@ -162,6 +167,12 @@ docker-compose logs -f
|
||||
|
||||
更新镜像:`docker-compose up -d --build`
|
||||
|
||||
#### Zeabur 一键部署(Dockerfile)
|
||||
|
||||
1. 点击上方 “Deploy on Zeabur” 按钮,一键部署。
|
||||
2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。
|
||||
3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。
|
||||
|
||||
### 方式三:Vercel 部署
|
||||
|
||||
1. Fork 仓库到自己的 GitHub
|
||||
@@ -462,6 +473,7 @@ npm ci --prefix webui && npm run build --prefix webui
|
||||
|
||||
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
|
||||
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`)+ `sha256sums.txt`
|
||||
- **容器镜像发布**:仅推送到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
||||
- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件、配置示例、README、LICENSE
|
||||
|
||||
## 免责声明
|
||||
|
||||
12
README.en.md
12
README.en.md
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
|
||||
</p>
|
||||
|
||||
# DS2API
|
||||
|
||||
[](LICENSE)
|
||||
@@ -5,6 +9,7 @@
|
||||

|
||||
[](https://github.com/CJackHwang/ds2api/releases)
|
||||
[](DEPLOY.en.md)
|
||||
[](https://zeabur.com/templates/L4CFHP)
|
||||
|
||||
Language: [中文](README.MD) | [English](README.en.md)
|
||||
|
||||
@@ -162,6 +167,12 @@ docker-compose logs -f
|
||||
|
||||
Rebuild after updates: `docker-compose up -d --build`
|
||||
|
||||
#### Zeabur One-Click (Dockerfile)
|
||||
|
||||
1. Click the “Deploy on Zeabur” button above to deploy.
|
||||
2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions.
|
||||
3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`).
|
||||
|
||||
### Option 3: Vercel
|
||||
|
||||
1. Fork this repo to your GitHub account
|
||||
@@ -462,6 +473,7 @@ Workflow: `.github/workflows/release-artifacts.yml`
|
||||
|
||||
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
|
||||
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
|
||||
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
||||
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file, config template, README, LICENSE
|
||||
|
||||
## Disclaimer
|
||||
|
||||
63
assets/ds2api-icon.svg
Normal file
63
assets/ds2api-icon.svg
Normal file
@@ -0,0 +1,63 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DS2API icon">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="96" y1="96" x2="416" y2="416" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#06162D" />
|
||||
<stop offset="0.6" stop-color="#0A3A6A" />
|
||||
<stop offset="1" stop-color="#00B4D8" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 180) rotate(90) scale(260)">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.18" />
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="whale" x1="180" y1="140" x2="360" y2="360" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#EAF7FF" />
|
||||
<stop offset="1" stop-color="#BDEBFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle cx="256" cy="256" r="240" fill="url(#bg)" />
|
||||
<circle cx="256" cy="256" r="240" fill="url(#glow)" />
|
||||
<circle cx="256" cy="256" r="240" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="8" />
|
||||
|
||||
<!-- subtle waves -->
|
||||
<path d="M104 338 C156 308 204 366 256 334 C308 302 356 360 408 330" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="12" stroke-linecap="round" />
|
||||
<path d="M124 372 C174 344 212 396 256 372 C300 348 338 396 388 368" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="10" stroke-linecap="round" />
|
||||
|
||||
<!-- whale tail (DeepSeek-inspired element, original design) -->
|
||||
<path
|
||||
d="M256 162
|
||||
C228 124 184 118 156 146
|
||||
C132 170 138 206 162 230
|
||||
C190 262 230 252 252 220
|
||||
C254 218 255 216 256 214
|
||||
C257 216 258 218 260 220
|
||||
C282 252 322 262 350 230
|
||||
C374 206 380 170 356 146
|
||||
C328 118 284 124 256 162 Z"
|
||||
fill="url(#whale)"
|
||||
/>
|
||||
<rect x="236" y="214" width="40" height="168" rx="20" fill="url(#whale)" />
|
||||
|
||||
<!-- API nodes -->
|
||||
<g opacity="0.55" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="6" stroke-linecap="round">
|
||||
<path d="M156 236 L208 206" />
|
||||
<path d="M356 236 L304 206" />
|
||||
<path d="M208 206 L232 172" />
|
||||
<circle cx="156" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
|
||||
<circle cx="208" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
|
||||
<circle cx="232" cy="172" r="10" fill="#FFFFFF" fill-opacity="0.28" />
|
||||
<circle cx="304" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
|
||||
<circle cx="356" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
|
||||
</g>
|
||||
|
||||
<!-- tiny sparkle -->
|
||||
<path
|
||||
d="M378 164
|
||||
C372 170 366 174 358 176
|
||||
C366 178 372 182 378 188
|
||||
C380 180 384 176 392 176
|
||||
C384 174 380 170 378 164 Z"
|
||||
fill="#FFFFFF"
|
||||
fill-opacity="0.32"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,18 +1,14 @@
|
||||
services:
|
||||
ds2api:
|
||||
build: .
|
||||
image: ds2api:latest
|
||||
container_name: ds2api
|
||||
ports:
|
||||
- "${PORT:-5001}:${PORT:-5001}"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
services:
|
||||
ds2api:
|
||||
image: crpi-cnazxqmg4avmg4fq.cn-beijing.personal.cr.aliyuncs.com/ronghuaxueleng/ds2api:latest
|
||||
container_name: ds2api
|
||||
restart: always
|
||||
ports:
|
||||
- "6011:5001"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json # 配置文件
|
||||
- ./.env:/app/.env # 环境变量
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- LOG_LEVEL=INFO
|
||||
- DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api}
|
||||
|
||||
@@ -183,6 +183,66 @@ func TestHandleClaudeStreamRealtimeToolSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
|
||||
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
contentBlock, _ := f.Payload["content_block"].(map[string]any)
|
||||
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "search" {
|
||||
foundToolUse = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundToolUse {
|
||||
t.Fatalf("expected tool_use block from thinking fallback, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
|
||||
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: {"p":"response/content","v":"normal answer"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
contentBlock, _ := f.Payload["content_block"].(map[string]any)
|
||||
if contentBlock["type"] == "tool_use" {
|
||||
t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
foundEndTurn := false
|
||||
for _, f := range findClaudeFrames(frames, "message_delta") {
|
||||
delta, _ := f.Payload["delta"].(map[string]any)
|
||||
if delta["stop_reason"] == "end_turn" {
|
||||
foundEndTurn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundEndTurn {
|
||||
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
|
||||
@@ -141,6 +141,34 @@ func TestBuildClaudeToolPromptMultipleTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) {
|
||||
tools := []any{
|
||||
map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "search",
|
||||
"description": "Search via function tool",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
prompt := buildClaudeToolPrompt(tools)
|
||||
if !containsStr(prompt, "Tool: search") {
|
||||
t.Fatalf("expected OpenAI-style function tool name in prompt, got: %q", prompt)
|
||||
}
|
||||
if !containsStr(prompt, "Search via function tool") {
|
||||
t.Fatalf("expected OpenAI-style function tool description in prompt, got: %q", prompt)
|
||||
}
|
||||
if !containsStr(prompt, "\"q\"") {
|
||||
t.Fatalf("expected parameters schema serialized in prompt, got: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) {
|
||||
tools := []any{"not a map"}
|
||||
prompt := buildClaudeToolPrompt(tools)
|
||||
@@ -237,6 +265,21 @@ func TestExtractClaudeToolNamesNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaudeToolNamesSupportsOpenAIStyleFunctionTool(t *testing.T) {
|
||||
tools := []any{
|
||||
map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "search",
|
||||
},
|
||||
},
|
||||
}
|
||||
names := extractClaudeToolNames(tools)
|
||||
if len(names) != 1 || names[0] != "search" {
|
||||
t.Fatalf("expected [search], got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── toMessageMaps ───────────────────────────────────────────────────
|
||||
|
||||
func TestToMessageMapsNormal(t *testing.T) {
|
||||
|
||||
@@ -46,9 +46,8 @@ func buildClaudeToolPrompt(tools []any) string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := m["name"].(string)
|
||||
desc, _ := m["description"].(string)
|
||||
schema, _ := json.Marshal(m["input_schema"])
|
||||
name, desc, schemaObj := extractClaudeToolMeta(m)
|
||||
schema, _ := json.Marshal(schemaObj)
|
||||
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
|
||||
}
|
||||
parts = append(parts,
|
||||
@@ -98,13 +97,43 @@ func extractClaudeToolNames(tools []any) []string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name, ok := m["name"].(string); ok && name != "" {
|
||||
name, _, _ := extractClaudeToolMeta(m)
|
||||
if name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
|
||||
name, _ := m["name"].(string)
|
||||
desc, _ := m["description"].(string)
|
||||
schemaObj := m["input_schema"]
|
||||
if schemaObj == nil {
|
||||
schemaObj = m["parameters"]
|
||||
}
|
||||
|
||||
if fn, ok := m["function"].(map[string]any); ok {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name, _ = fn["name"].(string)
|
||||
}
|
||||
if strings.TrimSpace(desc) == "" {
|
||||
desc, _ = fn["description"].(string)
|
||||
}
|
||||
if schemaObj == nil {
|
||||
if v, ok := fn["input_schema"]; ok {
|
||||
schemaObj = v
|
||||
}
|
||||
}
|
||||
if schemaObj == nil {
|
||||
if v, ok := fn["parameters"]; ok {
|
||||
schemaObj = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
|
||||
}
|
||||
|
||||
func toMessageMaps(v any) []map[string]any {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
|
||||
@@ -46,6 +46,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
|
||||
|
||||
if s.bufferToolContent {
|
||||
detected := util.ParseToolCalls(finalText, s.toolNames)
|
||||
if len(detected) == 0 && finalText == "" && finalThinking != "" {
|
||||
detected = util.ParseToolCalls(finalThinking, s.toolNames)
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
stopReason = "tool_use"
|
||||
for i, tc := range detected {
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) {
|
||||
func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
@@ -393,8 +393,34 @@ func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) {
|
||||
if streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("expected raw tool_calls json to remain in content for unknown schema name: %s", rec.Body.String())
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h.handleStream(rec, req, resp, "cid5b", "deepseek-chat", "prompt", false, false, []string{"search"})
|
||||
|
||||
frames, done := parseSSEDataFrames(t, rec.Body.String())
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||
|
||||
@@ -200,9 +200,14 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
if insideCodeFence(state.recentTextTail + prefixPart) {
|
||||
return captured, nil, "", true
|
||||
}
|
||||
parsed := util.ParseStandaloneToolCalls(obj, toolNames)
|
||||
if len(parsed) == 0 {
|
||||
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
||||
if len(parsed.Calls) == 0 {
|
||||
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
|
||||
// Parsed as tool-call payload but rejected by schema/policy:
|
||||
// consume it to avoid leaking raw tool_calls JSON to user content.
|
||||
return prefixPart, nil, suffixPart, true
|
||||
}
|
||||
return captured, nil, "", true
|
||||
}
|
||||
return prefixPart, parsed, suffixPart, true
|
||||
return prefixPart, parsed.Calls, suffixPart, true
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type ConfigStore interface {
|
||||
Accounts() []config.Account
|
||||
FindAccount(identifier string) (config.Account, bool)
|
||||
UpdateAccountToken(identifier, token string) error
|
||||
UpdateAccountTestStatus(identifier, status string) error
|
||||
Update(mutator func(*config.Config) error) error
|
||||
ExportJSONAndBase64() (string, string, error)
|
||||
IsEnvBacked() bool
|
||||
|
||||
@@ -1,114 +1,128 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
page := intFromQuery(r, "page", 1)
|
||||
pageSize := intFromQuery(r, "page_size", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 1
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
total := len(accounts)
|
||||
reverseAccounts(accounts)
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, acc := range accounts[start:end] {
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
preview := ""
|
||||
if token != "" {
|
||||
if len(token) > 20 {
|
||||
preview = token[:20] + "..."
|
||||
} else {
|
||||
preview = token
|
||||
}
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"identifier": acc.Identifier(),
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"has_password": acc.Password != "",
|
||||
"has_token": token != "",
|
||||
"token_preview": preview,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
}
|
||||
|
||||
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
acc := toAccount(req)
|
||||
if acc.Identifier() == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, a := range c.Accounts {
|
||||
if acc.Email != "" && a.Email == acc.Email {
|
||||
return fmt.Errorf("邮箱已存在")
|
||||
}
|
||||
if acc.Mobile != "" && a.Mobile == acc.Mobile {
|
||||
return fmt.Errorf("手机号已存在")
|
||||
}
|
||||
}
|
||||
c.Accounts = append(c.Accounts, acc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := chi.URLParam(r, "identifier")
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, a := range c.Accounts {
|
||||
if accountMatchesIdentifier(a, identifier) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("账号不存在")
|
||||
}
|
||||
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
page := intFromQuery(r, "page", 1)
|
||||
pageSize := intFromQuery(r, "page_size", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 1
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
reverseAccounts(accounts)
|
||||
q := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q")))
|
||||
if q != "" {
|
||||
filtered := make([]config.Account, 0, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
id := strings.ToLower(acc.Identifier())
|
||||
if strings.Contains(id, q) ||
|
||||
strings.Contains(strings.ToLower(acc.Email), q) ||
|
||||
strings.Contains(strings.ToLower(acc.Mobile), q) {
|
||||
filtered = append(filtered, acc)
|
||||
}
|
||||
}
|
||||
accounts = filtered
|
||||
}
|
||||
total := len(accounts)
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, acc := range accounts[start:end] {
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
preview := ""
|
||||
if token != "" {
|
||||
if len(token) > 20 {
|
||||
preview = token[:20] + "..."
|
||||
} else {
|
||||
preview = token
|
||||
}
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"identifier": acc.Identifier(),
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"has_password": acc.Password != "",
|
||||
"has_token": token != "",
|
||||
"token_preview": preview,
|
||||
"test_status": acc.TestStatus,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
}
|
||||
|
||||
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
acc := toAccount(req)
|
||||
if acc.Identifier() == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, a := range c.Accounts {
|
||||
if acc.Email != "" && a.Email == acc.Email {
|
||||
return fmt.Errorf("邮箱已存在")
|
||||
}
|
||||
if acc.Mobile != "" && a.Mobile == acc.Mobile {
|
||||
return fmt.Errorf("手机号已存在")
|
||||
}
|
||||
}
|
||||
c.Accounts = append(c.Accounts, acc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := chi.URLParam(r, "identifier")
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, a := range c.Accounts {
|
||||
if accountMatchesIdentifier(a, identifier) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("账号不存在")
|
||||
}
|
||||
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
@@ -1,204 +1,209 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := findAccountByIdentifier(h.Store, identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
message, _ := req["message"].(string)
|
||||
result := h.testAccount(r.Context(), acc, model, message)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
if len(accounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Concurrent testing with a semaphore to limit parallelism.
|
||||
const maxConcurrency = 5
|
||||
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
|
||||
return h.testAccount(r.Context(), account, model, "")
|
||||
})
|
||||
|
||||
success := 0
|
||||
for _, res := range results {
|
||||
if ok, _ := res["success"].(bool); ok {
|
||||
success++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
|
||||
}
|
||||
|
||||
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
results := make([]map[string]any, len(accounts))
|
||||
var wg sync.WaitGroup
|
||||
for i, acc := range accounts {
|
||||
wg.Add(1)
|
||||
go func(idx int, account config.Account) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{} // acquire
|
||||
defer func() { <-sem }() // release
|
||||
results[idx] = testFn(idx, account)
|
||||
}(i, acc)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
|
||||
start := time.Now()
|
||||
result := map[string]any{"account": acc.Identifier(), "success": false, "response_time": 0, "message": "", "model": model}
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
if token == "" {
|
||||
newToken, err := h.DS.Login(ctx, acc)
|
||||
if err != nil {
|
||||
result["message"] = "登录失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
|
||||
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(ctx, acc)
|
||||
if loginErr != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
authCtx.DeepSeekToken = token
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
result["success"] = true
|
||||
result["message"] = "API 测试成功(仅会话创建)"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
thinking, search, ok := config.GetModelConfig(model)
|
||||
if !ok {
|
||||
thinking, search = false, false
|
||||
}
|
||||
_ = search
|
||||
pow, err := h.DS.GetPow(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "获取 PoW 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
|
||||
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
|
||||
if err != nil {
|
||||
result["message"] = "请求失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
collected := sse.CollectStream(resp, thinking, true)
|
||||
result["success"] = true
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
if collected.Text != "" {
|
||||
result["message"] = collected.Text
|
||||
} else {
|
||||
result["message"] = "(无回复内容)"
|
||||
}
|
||||
if collected.Thinking != "" {
|
||||
result["thinking"] = collected.Thinking
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
apiKey, _ := req["api_key"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
if message == "" {
|
||||
message = "你好"
|
||||
}
|
||||
if apiKey == "" {
|
||||
keys := h.Store.Snapshot().Keys
|
||||
if len(keys) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
|
||||
return
|
||||
}
|
||||
apiKey = keys[0]
|
||||
}
|
||||
host := r.Host
|
||||
scheme := "http"
|
||||
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
|
||||
b, _ := json.Marshal(payload)
|
||||
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := findAccountByIdentifier(h.Store, identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
message, _ := req["message"].(string)
|
||||
result := h.testAccount(r.Context(), acc, model, message)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
if len(accounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Concurrent testing with a semaphore to limit parallelism.
|
||||
const maxConcurrency = 5
|
||||
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
|
||||
return h.testAccount(r.Context(), account, model, "")
|
||||
})
|
||||
|
||||
success := 0
|
||||
for _, res := range results {
|
||||
if ok, _ := res["success"].(bool); ok {
|
||||
success++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
|
||||
}
|
||||
|
||||
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
results := make([]map[string]any, len(accounts))
|
||||
var wg sync.WaitGroup
|
||||
for i, acc := range accounts {
|
||||
wg.Add(1)
|
||||
go func(idx int, account config.Account) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{} // acquire
|
||||
defer func() { <-sem }() // release
|
||||
results[idx] = testFn(idx, account)
|
||||
}(i, acc)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
|
||||
start := time.Now()
|
||||
identifier := acc.Identifier()
|
||||
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model}
|
||||
defer func() {
|
||||
status := "failed"
|
||||
if ok, _ := result["success"].(bool); ok {
|
||||
status = "ok"
|
||||
}
|
||||
_ = h.Store.UpdateAccountTestStatus(identifier, status)
|
||||
}()
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
if token == "" {
|
||||
newToken, err := h.DS.Login(ctx, acc)
|
||||
if err != nil {
|
||||
result["message"] = "登录失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
|
||||
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(ctx, acc)
|
||||
if loginErr != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
authCtx.DeepSeekToken = token
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "你是谁?"
|
||||
}
|
||||
thinking, search, ok := config.GetModelConfig(model)
|
||||
if !ok {
|
||||
thinking, search = false, false
|
||||
}
|
||||
_ = search
|
||||
pow, err := h.DS.GetPow(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "获取 PoW 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
|
||||
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
|
||||
if err != nil {
|
||||
result["message"] = "请求失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
collected := sse.CollectStream(resp, thinking, true)
|
||||
result["success"] = true
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
if collected.Text != "" {
|
||||
result["message"] = collected.Text
|
||||
} else {
|
||||
result["message"] = "(无回复内容)"
|
||||
}
|
||||
if collected.Thinking != "" {
|
||||
result["thinking"] = collected.Thinking
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
apiKey, _ := req["api_key"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
if message == "" {
|
||||
message = "你好"
|
||||
}
|
||||
if apiKey == "" {
|
||||
keys := h.Store.Snapshot().Keys
|
||||
if len(keys) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
|
||||
return
|
||||
}
|
||||
apiKey = keys[0]
|
||||
}
|
||||
host := r.Host
|
||||
scheme := "http"
|
||||
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
|
||||
b, _ := json.Marshal(payload)
|
||||
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TestStatus string `json:"test_status,omitempty"`
|
||||
}
|
||||
|
||||
type CompatConfig struct {
|
||||
|
||||
@@ -97,6 +97,18 @@ func (s *Store) FindAccount(identifier string) (Account, bool) {
|
||||
return Account{}, false
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAccountTestStatus(identifier, status string) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx, ok := s.findAccountIndexLocked(identifier)
|
||||
if !ok {
|
||||
return errors.New("account not found")
|
||||
}
|
||||
s.cfg.Accounts[idx].TestStatus = status
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAccountToken(identifier, token string) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
|
||||
func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := util.ParseToolCalls(finalText, toolNames)
|
||||
if len(detected) == 0 && finalText == "" && finalThinking != "" {
|
||||
detected = util.ParseToolCalls(finalThinking, toolNames)
|
||||
}
|
||||
content := make([]map[string]any, 0, 4)
|
||||
if finalThinking != "" {
|
||||
content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking})
|
||||
|
||||
62
internal/format/claude/render_test.go
Normal file
62
internal/format/claude/render_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T) {
|
||||
resp := BuildMessageResponse(
|
||||
"msg_1",
|
||||
"claude-sonnet-4-5",
|
||||
[]any{map[string]any{"role": "user", "content": "hi"}},
|
||||
`{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`,
|
||||
"",
|
||||
[]string{"search"},
|
||||
)
|
||||
|
||||
if resp["stop_reason"] != "tool_use" {
|
||||
t.Fatalf("expected stop_reason=tool_use, got=%#v", resp["stop_reason"])
|
||||
}
|
||||
content, _ := resp["content"].([]map[string]any)
|
||||
if len(content) < 2 {
|
||||
t.Fatalf("expected thinking + tool_use content blocks, got=%#v", resp["content"])
|
||||
}
|
||||
last := content[len(content)-1]
|
||||
if last["type"] != "tool_use" {
|
||||
t.Fatalf("expected last content block tool_use, got=%#v", last["type"])
|
||||
}
|
||||
if last["name"] != "search" {
|
||||
t.Fatalf("expected tool name search, got=%#v", last["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageResponseSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
|
||||
resp := BuildMessageResponse(
|
||||
"msg_1",
|
||||
"claude-sonnet-4-5",
|
||||
[]any{map[string]any{"role": "user", "content": "hi"}},
|
||||
`{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`,
|
||||
"normal answer",
|
||||
[]string{"search"},
|
||||
)
|
||||
|
||||
if resp["stop_reason"] != "end_turn" {
|
||||
t.Fatalf("expected stop_reason=end_turn, got=%#v", resp["stop_reason"])
|
||||
}
|
||||
|
||||
content, _ := resp["content"].([]map[string]any)
|
||||
foundText := false
|
||||
foundTool := false
|
||||
for _, block := range content {
|
||||
if block["type"] == "text" && block["text"] == "normal answer" {
|
||||
foundText = true
|
||||
}
|
||||
if block["type"] == "tool_use" {
|
||||
foundTool = true
|
||||
}
|
||||
}
|
||||
if !foundText {
|
||||
t.Fatalf("expected text block with finalText, got=%#v", resp["content"])
|
||||
}
|
||||
if foundTool {
|
||||
t.Fatalf("unexpected tool_use block when finalText exists, got=%#v", resp["content"])
|
||||
}
|
||||
}
|
||||
@@ -263,14 +263,6 @@ function filterToolCalls(parsed, toolNames) {
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
}
|
||||
if (out.length === 0 && parsed.length > 0) {
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,8 +205,17 @@ function consumeToolCapture(state, toolNames) {
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
const rawParsed = parseStandaloneToolCalls(captured.slice(start, obj.end), []);
|
||||
const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames);
|
||||
if (parsed.length === 0) {
|
||||
if (rawParsed.length > 0 && Array.isArray(toolNames) && toolNames.length > 0) {
|
||||
return {
|
||||
ready: true,
|
||||
prefix: prefixPart,
|
||||
calls: [],
|
||||
suffix: suffixPart,
|
||||
};
|
||||
}
|
||||
if (state.toolNameSent) {
|
||||
return {
|
||||
ready: true,
|
||||
|
||||
@@ -89,8 +89,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
|
||||
|
||||
func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) {
|
||||
allowed := map[string]struct{}{}
|
||||
allowedCanonical := map[string]string{}
|
||||
for _, name := range availableToolNames {
|
||||
allowed[name] = struct{}{}
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
allowed[trimmed] = struct{}{}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if _, exists := allowedCanonical[lower]; !exists {
|
||||
allowedCanonical[lower] = trimmed
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
rejectedSet := map[string]struct{}{}
|
||||
@@ -112,10 +121,17 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[tc.Name]; !ok {
|
||||
matchedName := ""
|
||||
if _, ok := allowed[tc.Name]; ok {
|
||||
matchedName = tc.Name
|
||||
} else if canonical, ok := allowedCanonical[strings.ToLower(tc.Name)]; ok {
|
||||
matchedName = canonical
|
||||
}
|
||||
if matchedName == "" {
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
tc.Name = matchedName
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,17 @@ func TestParseToolCallsRejectsUnknownToolName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
|
||||
res := ParseToolCallsDetailed(text, []string{"search"})
|
||||
|
||||
566
start.mjs
Normal file
566
start.mjs
Normal file
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* DS2API 启动脚本 - 交互式菜单
|
||||
*
|
||||
* 使用方法:
|
||||
* node start.mjs # 显示交互式菜单
|
||||
* node start.mjs dev # 开发模式(后端 + 前端热重载)
|
||||
* node start.mjs prod # 生产模式(编译后运行)
|
||||
* node start.mjs build # 编译后端二进制
|
||||
* node start.mjs webui # 构建前端静态文件
|
||||
* node start.mjs install # 安装前端依赖
|
||||
* node start.mjs stop # 停止所有服务
|
||||
* node start.mjs status # 查看服务状态
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 判断是否为 Windows
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// 编译产物路径
|
||||
const BINARY = join(__dirname, isWindows ? 'ds2api.exe' : 'ds2api');
|
||||
|
||||
// 配置(从环境变量读取,与 Go 主程序保持一致)
|
||||
const CONFIG = {
|
||||
port: process.env.PORT || '5001',
|
||||
frontendPort: 5173,
|
||||
logLevel: process.env.LOG_LEVEL || 'INFO',
|
||||
adminKey: process.env.DS2API_ADMIN_KEY || 'admin',
|
||||
webuiDir: join(__dirname, 'webui'),
|
||||
staticAdminDir: process.env.DS2API_STATIC_ADMIN_DIR || join(__dirname, 'static', 'admin'),
|
||||
};
|
||||
|
||||
// 国内镜像配置
|
||||
const MIRRORS = {
|
||||
goproxy: process.env.GOPROXY || 'https://goproxy.cn,direct',
|
||||
npm: process.env.NPM_REGISTRY || 'https://registry.npmmirror.com',
|
||||
};
|
||||
|
||||
// 存储子进程
|
||||
const processes = [];
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
const log = {
|
||||
info: (msg) => console.log(`${colors.cyan}[INFO]${colors.reset} ${msg}`),
|
||||
success: (msg) => console.log(`${colors.green}[OK]${colors.reset} ${msg}`),
|
||||
warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`),
|
||||
error: (msg) => console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`),
|
||||
title: (msg) => console.log(`\n${colors.bright}${colors.magenta}${msg}${colors.reset}`),
|
||||
};
|
||||
|
||||
// 清理并退出
|
||||
function cleanup() {
|
||||
console.log('\n');
|
||||
log.info('正在关闭所有服务...');
|
||||
processes.forEach(proc => {
|
||||
if (proc && !proc.killed) {
|
||||
proc.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
log.success('已退出');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
// 检查命令是否存在
|
||||
function commandExists(cmd) {
|
||||
try {
|
||||
execSync(`${isWindows ? 'where' : 'which'} ${cmd}`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Go 是否安装
|
||||
function checkGo() {
|
||||
return commandExists('go');
|
||||
}
|
||||
|
||||
// 获取 Go 版本
|
||||
function getGoVersion() {
|
||||
try {
|
||||
return execSync('go version', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查前端依赖是否已安装
|
||||
function checkFrontendDeps() {
|
||||
if (!existsSync(CONFIG.webuiDir)) return null;
|
||||
return existsSync(join(CONFIG.webuiDir, 'node_modules'));
|
||||
}
|
||||
|
||||
// 检查前端是否已构建
|
||||
function checkWebuiBuilt() {
|
||||
return existsSync(join(CONFIG.staticAdminDir, 'index.html'));
|
||||
}
|
||||
|
||||
// 检查后端二进制是否存在
|
||||
function binaryExists() {
|
||||
return existsSync(BINARY);
|
||||
}
|
||||
|
||||
// 查找占用端口的进程 PID
|
||||
function findPidByPort(port) {
|
||||
try {
|
||||
if (isWindows) {
|
||||
const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
|
||||
encoding: 'utf-8',
|
||||
shell: true,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
const pids = new Set();
|
||||
for (const line of output.trim().split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && pid !== '0') pids.add(pid);
|
||||
}
|
||||
return [...pids];
|
||||
} else {
|
||||
const output = execSync(`lsof -ti :${port}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
return output.trim().split('\n').filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取运行中的服务状态
|
||||
function getRunningStatus() {
|
||||
const backendPids = findPidByPort(CONFIG.port);
|
||||
const frontendPids = findPidByPort(CONFIG.frontendPort);
|
||||
return {
|
||||
backend: backendPids,
|
||||
frontend: frontendPids,
|
||||
isRunning: backendPids.length > 0 || frontendPids.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 停止服务
|
||||
async function stopServices() {
|
||||
const running = getRunningStatus();
|
||||
|
||||
if (!running.isRunning) {
|
||||
log.warn('没有检测到正在运行的服务');
|
||||
return;
|
||||
}
|
||||
|
||||
log.title('========== 停止服务 ==========');
|
||||
|
||||
const killProcess = async (pid) => {
|
||||
try {
|
||||
if (isWindows) {
|
||||
try {
|
||||
execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', shell: true });
|
||||
} catch {
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', shell: true });
|
||||
}
|
||||
} else {
|
||||
execSync(`kill -15 ${pid}`, { stdio: 'ignore' });
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
try {
|
||||
execSync(`kill -0 ${pid}`, { stdio: 'ignore' });
|
||||
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
||||
} catch { /* 进程已退出 */ }
|
||||
}
|
||||
} catch { /* 进程可能已退出 */ }
|
||||
};
|
||||
|
||||
if (running.backend.length > 0) {
|
||||
log.info(`停止后端服务 (端口 ${CONFIG.port}, PID: ${running.backend.join(', ')})...`);
|
||||
for (const pid of running.backend) await killProcess(pid);
|
||||
log.success('后端服务已停止');
|
||||
}
|
||||
|
||||
if (running.frontend.length > 0) {
|
||||
log.info(`停止前端服务 (端口 ${CONFIG.frontendPort}, PID: ${running.frontend.join(', ')})...`);
|
||||
for (const pid of running.frontend) await killProcess(pid);
|
||||
log.success('前端服务已停止');
|
||||
}
|
||||
}
|
||||
|
||||
// 安装前端依赖
|
||||
async function installFrontendDeps() {
|
||||
if (!existsSync(CONFIG.webuiDir)) {
|
||||
log.warn('webui 目录不存在,跳过前端依赖安装');
|
||||
return;
|
||||
}
|
||||
log.info(`安装前端依赖 (npm ci, registry: ${MIRRORS.npm})...`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], {
|
||||
cwd: CONFIG.webuiDir,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败')));
|
||||
});
|
||||
}
|
||||
|
||||
// 确保前端依赖已安装
|
||||
async function ensureFrontendDeps() {
|
||||
if (checkFrontendDeps() === false) {
|
||||
log.warn('检测到前端依赖未安装,正在安装...');
|
||||
await installFrontendDeps();
|
||||
}
|
||||
}
|
||||
|
||||
// 编译后端二进制
|
||||
async function buildBackend() {
|
||||
if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)');
|
||||
log.info(`编译后端二进制 (GOPROXY: ${MIRRORS.goproxy})...`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: { ...process.env, GOPROXY: MIRRORS.goproxy },
|
||||
});
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败')));
|
||||
});
|
||||
}
|
||||
|
||||
// 构建前端静态文件
|
||||
async function buildWebui() {
|
||||
if (!existsSync(CONFIG.webuiDir)) {
|
||||
log.warn('webui 目录不存在');
|
||||
return;
|
||||
}
|
||||
await ensureFrontendDeps();
|
||||
log.info('构建前端静态文件...');
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
'npm', ['run', 'build', '--', '--outDir', CONFIG.staticAdminDir, '--emptyOutDir'],
|
||||
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true }
|
||||
);
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败')));
|
||||
});
|
||||
}
|
||||
|
||||
// 启动后端(开发模式:go run,无需预编译)
|
||||
async function startBackendDev() {
|
||||
if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)');
|
||||
log.info(`启动后端(go run)... http://localhost:${CONFIG.port}`);
|
||||
const proc = spawn('go', ['run', './cmd/ds2api'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: CONFIG.port,
|
||||
LOG_LEVEL: CONFIG.logLevel,
|
||||
DS2API_ADMIN_KEY: CONFIG.adminKey,
|
||||
GOPROXY: MIRRORS.goproxy,
|
||||
},
|
||||
});
|
||||
processes.push(proc);
|
||||
return proc;
|
||||
}
|
||||
|
||||
// 启动后端(生产模式:运行编译好的二进制)
|
||||
async function startBackendProd() {
|
||||
if (!binaryExists()) {
|
||||
log.warn('未找到编译产物,正在编译...');
|
||||
await buildBackend();
|
||||
}
|
||||
log.info(`启动后端(二进制)... http://localhost:${CONFIG.port}`);
|
||||
const proc = spawn(BINARY, [], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: CONFIG.port,
|
||||
LOG_LEVEL: CONFIG.logLevel,
|
||||
DS2API_ADMIN_KEY: CONFIG.adminKey,
|
||||
},
|
||||
});
|
||||
processes.push(proc);
|
||||
return proc;
|
||||
}
|
||||
|
||||
// 启动前端开发服务器
|
||||
async function startFrontend() {
|
||||
if (!existsSync(CONFIG.webuiDir)) {
|
||||
log.warn('webui 目录不存在,跳过前端启动');
|
||||
return null;
|
||||
}
|
||||
await ensureFrontendDeps();
|
||||
log.info(`启动前端开发服务器... http://localhost:${CONFIG.frontendPort}`);
|
||||
const proc = spawn('npm', ['run', 'dev'], {
|
||||
cwd: CONFIG.webuiDir,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
processes.push(proc);
|
||||
return proc;
|
||||
}
|
||||
|
||||
// 显示状态信息
|
||||
function showStatus() {
|
||||
console.log('\n' + '─'.repeat(50));
|
||||
log.success(`后端 API: http://localhost:${CONFIG.port}`);
|
||||
log.success(`管理界面: http://localhost:${CONFIG.port}/admin`);
|
||||
if (existsSync(CONFIG.webuiDir)) {
|
||||
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
|
||||
}
|
||||
console.log('─'.repeat(50));
|
||||
log.info('按 Ctrl+C 停止所有服务\n');
|
||||
}
|
||||
|
||||
// 等待进程退出
|
||||
function waitForProcesses() {
|
||||
return new Promise(resolve => {
|
||||
const check = setInterval(() => {
|
||||
const activeCount = processes.filter(proc => proc.exitCode === null && proc.signalCode === null).length;
|
||||
if (activeCount === 0) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// 交互式菜单
|
||||
async function showMenu() {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const question = (prompt) => new Promise(resolve => rl.question(prompt, resolve));
|
||||
|
||||
console.clear();
|
||||
log.title('╔══════════════════════════════════════════╗');
|
||||
log.title('║ DS2API 启动脚本 (Go) ║');
|
||||
log.title('╚══════════════════════════════════════════╝');
|
||||
|
||||
// 环境状态
|
||||
const goVersion = getGoVersion();
|
||||
const frontendDeps = checkFrontendDeps();
|
||||
const webuiBuilt = checkWebuiBuilt();
|
||||
const hasBinary = binaryExists();
|
||||
const running = getRunningStatus();
|
||||
|
||||
const ok = (v) => v ? `${colors.green}✓${colors.reset}` : `${colors.yellow}✗${colors.reset}`;
|
||||
|
||||
console.log(`\n${colors.bright}环境状态:${colors.reset}`);
|
||||
console.log(` Go: ${goVersion ? `${colors.green}${goVersion}${colors.reset}` : `${colors.red}未安装${colors.reset}`}`);
|
||||
console.log(` 前端依赖: ${frontendDeps === null ? `${colors.dim}N/A${colors.reset}` : frontendDeps ? `${colors.green}已安装${colors.reset}` : `${colors.yellow}未安装${colors.reset}`}`);
|
||||
console.log(` 前端构建: ${ok(webuiBuilt)} ${webuiBuilt ? `(${CONFIG.staticAdminDir})` : '未构建'}`);
|
||||
console.log(` 后端二进制: ${ok(hasBinary)} ${hasBinary ? BINARY : '未编译'}`);
|
||||
|
||||
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
|
||||
console.log(` 后端 (:${CONFIG.port}): ${running.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||||
console.log(` 前端 (:${CONFIG.frontendPort}): ${running.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${running.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||||
|
||||
console.log(`\n${colors.bright}环境变量:${colors.reset}`);
|
||||
console.log(` PORT: ${colors.cyan}${CONFIG.port}${colors.reset}`);
|
||||
console.log(` LOG_LEVEL: ${colors.cyan}${CONFIG.logLevel}${colors.reset}`);
|
||||
console.log(` DS2API_ADMIN_KEY: ${colors.cyan}${CONFIG.adminKey}${colors.reset}`);
|
||||
console.log(` GOPROXY: ${colors.cyan}${MIRRORS.goproxy}${colors.reset}`);
|
||||
console.log(` NPM_REGISTRY: ${colors.cyan}${MIRRORS.npm}${colors.reset}`);
|
||||
console.log(`${colors.dim} 自定义: DS2API_ADMIN_KEY=密钥 PORT=5001 node start.mjs${colors.reset}`);
|
||||
|
||||
console.log(`
|
||||
${colors.bright}请选择操作:${colors.reset}
|
||||
|
||||
${colors.cyan}1.${colors.reset} 开发模式 (go run + 前端热重载)
|
||||
${colors.cyan}2.${colors.reset} 仅后端 (go run,无需编译)
|
||||
${colors.cyan}3.${colors.reset} 仅前端 (npm dev)
|
||||
${colors.cyan}4.${colors.reset} 生产模式 (编译后运行,前端已嵌入)
|
||||
${colors.cyan}5.${colors.reset} 编译后端 (go build)
|
||||
${colors.cyan}6.${colors.reset} 构建前端 (npm build → static/admin)
|
||||
${colors.cyan}7.${colors.reset} 安装前端依赖 (npm ci)
|
||||
${colors.red}8.${colors.reset} 停止所有服务
|
||||
${colors.cyan}0.${colors.reset} 退出
|
||||
`);
|
||||
|
||||
const choice = await question(`${colors.yellow}请输入选项 [1]: ${colors.reset}`);
|
||||
rl.close();
|
||||
|
||||
switch (choice.trim() || '1') {
|
||||
case '1':
|
||||
log.title('========== 开发模式 ==========');
|
||||
await startBackendDev();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await startFrontend();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case '2':
|
||||
log.title('========== 仅后端 (go run) ==========');
|
||||
await startBackendDev();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case '3':
|
||||
log.title('========== 仅前端 ==========');
|
||||
await startFrontend();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case '4':
|
||||
log.title('========== 生产模式 ==========');
|
||||
await startBackendProd();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case '5':
|
||||
log.title('========== 编译后端 ==========');
|
||||
await buildBackend();
|
||||
log.success(`编译完成:${BINARY}`);
|
||||
break;
|
||||
|
||||
case '6':
|
||||
log.title('========== 构建前端 ==========');
|
||||
await buildWebui();
|
||||
log.success('前端构建完成!');
|
||||
break;
|
||||
|
||||
case '7':
|
||||
log.title('========== 安装前端依赖 ==========');
|
||||
await installFrontendDeps();
|
||||
log.success('前端依赖安装完成!');
|
||||
break;
|
||||
|
||||
case '8':
|
||||
await stopServices();
|
||||
break;
|
||||
|
||||
case '0':
|
||||
log.info('再见!');
|
||||
process.exit(0);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.warn('无效选项');
|
||||
await showMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// 命令行参数处理
|
||||
async function main() {
|
||||
const cmd = process.argv[2];
|
||||
|
||||
if (!checkGo() && !['install', 'webui', 'stop', 'status', 'help', '-h', '--help'].includes(cmd)) {
|
||||
log.error('未找到 Go,请先安装 Go: https://go.dev/dl/');
|
||||
if (!cmd) {
|
||||
// 无 Go 时仍允许进入菜单(可以只操作前端)
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'dev':
|
||||
log.title('========== 开发模式 ==========');
|
||||
await startBackendDev();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await startFrontend();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case 'prod':
|
||||
log.title('========== 生产模式 ==========');
|
||||
await startBackendProd();
|
||||
showStatus();
|
||||
await waitForProcesses();
|
||||
break;
|
||||
|
||||
case 'build':
|
||||
await buildBackend();
|
||||
log.success(`编译完成:${BINARY}`);
|
||||
break;
|
||||
|
||||
case 'webui':
|
||||
await buildWebui();
|
||||
log.success('前端构建完成!');
|
||||
break;
|
||||
|
||||
case 'install':
|
||||
await installFrontendDeps();
|
||||
log.success('前端依赖安装完成!');
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
await stopServices();
|
||||
break;
|
||||
|
||||
case 'status': {
|
||||
const status = getRunningStatus();
|
||||
const goVer = getGoVersion();
|
||||
console.log(`\n${colors.bright}环境:${colors.reset}`);
|
||||
console.log(` Go: ${goVer || `${colors.red}未安装${colors.reset}`}`);
|
||||
console.log(`\n${colors.bright}服务状态:${colors.reset}`);
|
||||
console.log(` 后端 (:${CONFIG.port}): ${status.backend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.backend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}`);
|
||||
console.log(` 前端 (:${CONFIG.frontendPort}): ${status.frontend.length > 0 ? `${colors.green}运行中${colors.reset} (PID: ${status.frontend.join(', ')})` : `${colors.dim}未运行${colors.reset}`}\n`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'help':
|
||||
case '-h':
|
||||
case '--help':
|
||||
console.log(`
|
||||
${colors.bright}DS2API 启动脚本 (Go)${colors.reset}
|
||||
|
||||
${colors.cyan}使用方法:${colors.reset}
|
||||
node start.mjs 显示交互式菜单
|
||||
node start.mjs dev 开发模式 (go run + 前端热重载)
|
||||
node start.mjs prod 生产模式 (编译产物,前端已嵌入)
|
||||
node start.mjs build 编译后端二进制 (go build)
|
||||
node start.mjs webui 构建前端静态文件
|
||||
node start.mjs install 安装前端依赖 (npm ci)
|
||||
node start.mjs stop 停止所有服务
|
||||
node start.mjs status 查看服务状态
|
||||
|
||||
${colors.cyan}常用环境变量:${colors.reset}
|
||||
PORT 后端端口 (默认: 5001)
|
||||
LOG_LEVEL 日志级别: DEBUG|INFO|WARN|ERROR (默认: INFO)
|
||||
DS2API_ADMIN_KEY 管理员密钥 (默认: admin)
|
||||
DS2API_CONFIG_PATH 配置文件路径 (默认: config.json)
|
||||
GOPROXY Go 模块代理 (默认: https://goproxy.cn,direct)
|
||||
NPM_REGISTRY npm 镜像源 (默认: https://registry.npmmirror.com)
|
||||
|
||||
${colors.cyan}示例:${colors.reset}
|
||||
DS2API_ADMIN_KEY=mykey PORT=8080 node start.mjs dev
|
||||
GOPROXY=off NPM_REGISTRY=https://registry.npmjs.org node start.mjs dev
|
||||
`);
|
||||
break;
|
||||
|
||||
default:
|
||||
await showMenu();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
log.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -52,11 +52,19 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseToolCalls still intercepts unknown schema names to avoid leaks', () => {
|
||||
test('parseToolCalls drops unknown schema names when toolNames is provided', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
|
||||
});
|
||||
const calls = parseToolCalls(payload, ['search']);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('parseToolCalls keeps unknown names when toolNames is empty', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
|
||||
});
|
||||
const calls = parseToolCalls(payload, []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].name, 'not_in_schema');
|
||||
});
|
||||
@@ -144,6 +152,20 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
|
||||
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
|
||||
});
|
||||
|
||||
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
|
||||
const events = runSieve(
|
||||
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
|
||||
['read_file'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
|
||||
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(hasToolDelta, false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
assert.equal(leakedText.includes('后置正文G。'), true);
|
||||
});
|
||||
|
||||
test('sieve emits incremental tool_call_deltas for split arguments payload', () => {
|
||||
const state = createToolSieveState();
|
||||
const first = processToolSieveChunk(
|
||||
|
||||
@@ -5,4 +5,18 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
./tests/scripts/check-node-split-syntax.sh
|
||||
node --test tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js "$@"
|
||||
|
||||
# Keep Node's file-level test scheduling serial to avoid intermittent cross-file
|
||||
# interference when multiple suites import mutable module singletons.
|
||||
NODE_TEST_LOG="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$NODE_TEST_LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! node --test --test-concurrency=1 tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js "$@" 2>&1 | tee "$NODE_TEST_LOG"; then
|
||||
echo
|
||||
echo "[run-unit-node] Node tests failed. 失败摘要如下:"
|
||||
rg -n "^(not ok|# fail)|ERR_TEST_FAILURE" "$NODE_TEST_LOG" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,113 +1,121 @@
|
||||
import { useI18n } from '../../i18n'
|
||||
import { useAccountsData } from './useAccountsData'
|
||||
import { useAccountActions } from './useAccountActions'
|
||||
import QueueCards from './QueueCards'
|
||||
import ApiKeysPanel from './ApiKeysPanel'
|
||||
import AccountsTable from './AccountsTable'
|
||||
import AddKeyModal from './AddKeyModal'
|
||||
import AddAccountModal from './AddAccountModal'
|
||||
|
||||
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
|
||||
const { t } = useI18n()
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
} = useAccountsData({ apiFetch })
|
||||
|
||||
const {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
setNewKey,
|
||||
copiedKey,
|
||||
setCopiedKey,
|
||||
newAccount,
|
||||
setNewAccount,
|
||||
loading,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
} = useAccountActions({
|
||||
apiFetch,
|
||||
t,
|
||||
onMessage,
|
||||
onRefresh,
|
||||
config,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<QueueCards queueStatus={queueStatus} t={t} />
|
||||
|
||||
<ApiKeysPanel
|
||||
t={t}
|
||||
config={config}
|
||||
keysExpanded={keysExpanded}
|
||||
setKeysExpanded={setKeysExpanded}
|
||||
setShowAddKey={setShowAddKey}
|
||||
copiedKey={copiedKey}
|
||||
setCopiedKey={setCopiedKey}
|
||||
onDeleteKey={deleteKey}
|
||||
/>
|
||||
|
||||
<AccountsTable
|
||||
t={t}
|
||||
accounts={accounts}
|
||||
loadingAccounts={loadingAccounts}
|
||||
testing={testing}
|
||||
testingAll={testingAll}
|
||||
batchProgress={batchProgress}
|
||||
totalAccounts={totalAccounts}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
resolveAccountIdentifier={resolveAccountIdentifier}
|
||||
onTestAll={testAllAccounts}
|
||||
onShowAddAccount={() => setShowAddAccount(true)}
|
||||
onTestAccount={testAccount}
|
||||
onDeleteAccount={deleteAccount}
|
||||
onPrevPage={() => fetchAccounts(page - 1)}
|
||||
onNextPage={() => fetchAccounts(page + 1)}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
show={showAddKey}
|
||||
t={t}
|
||||
newKey={newKey}
|
||||
setNewKey={setNewKey}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddKey(false)}
|
||||
onAdd={addKey}
|
||||
/>
|
||||
|
||||
<AddAccountModal
|
||||
show={showAddAccount}
|
||||
t={t}
|
||||
newAccount={newAccount}
|
||||
setNewAccount={setNewAccount}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onAdd={addAccount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useI18n } from '../../i18n'
|
||||
import { useAccountsData } from './useAccountsData'
|
||||
import { useAccountActions } from './useAccountActions'
|
||||
import QueueCards from './QueueCards'
|
||||
import ApiKeysPanel from './ApiKeysPanel'
|
||||
import AccountsTable from './AccountsTable'
|
||||
import AddKeyModal from './AddKeyModal'
|
||||
import AddAccountModal from './AddAccountModal'
|
||||
|
||||
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
|
||||
const { t } = useI18n()
|
||||
const apiFetch = authFetch || fetch
|
||||
|
||||
const {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
changePageSize,
|
||||
resolveAccountIdentifier,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
} = useAccountsData({ apiFetch })
|
||||
|
||||
const {
|
||||
showAddKey,
|
||||
setShowAddKey,
|
||||
showAddAccount,
|
||||
setShowAddAccount,
|
||||
newKey,
|
||||
setNewKey,
|
||||
copiedKey,
|
||||
setCopiedKey,
|
||||
newAccount,
|
||||
setNewAccount,
|
||||
loading,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
addKey,
|
||||
deleteKey,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
testAllAccounts,
|
||||
} = useAccountActions({
|
||||
apiFetch,
|
||||
t,
|
||||
onMessage,
|
||||
onRefresh,
|
||||
config,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<QueueCards queueStatus={queueStatus} t={t} />
|
||||
|
||||
<ApiKeysPanel
|
||||
t={t}
|
||||
config={config}
|
||||
keysExpanded={keysExpanded}
|
||||
setKeysExpanded={setKeysExpanded}
|
||||
setShowAddKey={setShowAddKey}
|
||||
copiedKey={copiedKey}
|
||||
setCopiedKey={setCopiedKey}
|
||||
onDeleteKey={deleteKey}
|
||||
/>
|
||||
|
||||
<AccountsTable
|
||||
t={t}
|
||||
accounts={accounts}
|
||||
loadingAccounts={loadingAccounts}
|
||||
testing={testing}
|
||||
testingAll={testingAll}
|
||||
batchProgress={batchProgress}
|
||||
totalAccounts={totalAccounts}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
totalPages={totalPages}
|
||||
resolveAccountIdentifier={resolveAccountIdentifier}
|
||||
onTestAll={testAllAccounts}
|
||||
onShowAddAccount={() => setShowAddAccount(true)}
|
||||
onTestAccount={testAccount}
|
||||
onDeleteAccount={deleteAccount}
|
||||
onPrevPage={() => fetchAccounts(page - 1)}
|
||||
onNextPage={() => fetchAccounts(page + 1)}
|
||||
onPageSizeChange={changePageSize}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<AddKeyModal
|
||||
show={showAddKey}
|
||||
t={t}
|
||||
newKey={newKey}
|
||||
setNewKey={setNewKey}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddKey(false)}
|
||||
onAdd={addKey}
|
||||
/>
|
||||
|
||||
<AddAccountModal
|
||||
show={showAddAccount}
|
||||
t={t}
|
||||
newAccount={newAccount}
|
||||
setNewAccount={setNewAccount}
|
||||
loading={loading}
|
||||
onClose={() => setShowAddAccount(false)}
|
||||
onAdd={addAccount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,149 +1,191 @@
|
||||
import { ChevronLeft, ChevronRight, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function AccountsTable({
|
||||
t,
|
||||
accounts,
|
||||
loadingAccounts,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
totalAccounts,
|
||||
page,
|
||||
totalPages,
|
||||
resolveAccountIdentifier,
|
||||
onTestAll,
|
||||
onShowAddAccount,
|
||||
onTestAccount,
|
||||
onDeleteAccount,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onTestAll}
|
||||
disabled={testingAll || totalAccounts === 0}
|
||||
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
||||
>
|
||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||
{t('accountManager.testAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowAddAccount}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('accountManager.addAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testingAll && batchProgress.total > 0 && (
|
||||
<div className="p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
|
||||
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
|
||||
<div
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{batchProgress.results.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{batchProgress.results.map((r, i) => (
|
||||
<div key={i} className={clsx(
|
||||
"text-xs px-2 py-1 rounded border truncate",
|
||||
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{r.success ? '✓' : '✗'} {r.id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{loadingAccounts ? (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((acc, i) => {
|
||||
const id = resolveAccountIdentifier(acc)
|
||||
return (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.has_token ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{id || '-'}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? t('actions.testing') : t('actions.test')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAccount(id)}
|
||||
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('accountManager.noAccounts')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrevPage}
|
||||
disabled={page <= 1 || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={page >= totalPages || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function AccountsTable({
|
||||
t,
|
||||
accounts,
|
||||
loadingAccounts,
|
||||
testing,
|
||||
testingAll,
|
||||
batchProgress,
|
||||
totalAccounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
resolveAccountIdentifier,
|
||||
onTestAll,
|
||||
onShowAddAccount,
|
||||
onTestAccount,
|
||||
onDeleteAccount,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
onPageSizeChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
|
||||
const copyId = (id) => {
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 1500)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder={t('accountManager.searchPlaceholder')}
|
||||
className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
/>
|
||||
<button
|
||||
onClick={onTestAll}
|
||||
disabled={testingAll || totalAccounts === 0}
|
||||
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
|
||||
>
|
||||
{testingAll ? <span className="animate-spin mr-2">⟳</span> : <Play className="w-3 h-3 mr-2" />}
|
||||
{t('accountManager.testAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onShowAddAccount}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('accountManager.addAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testingAll && batchProgress.total > 0 && (
|
||||
<div className="p-4 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
|
||||
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
|
||||
<div
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{batchProgress.results.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
|
||||
{batchProgress.results.map((r, i) => (
|
||||
<div key={i} className={clsx(
|
||||
"text-xs px-2 py-1 rounded border truncate",
|
||||
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{r.success ? '✓' : '✗'} {r.id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{loadingAccounts ? (
|
||||
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((acc, i) => {
|
||||
const id = resolveAccountIdentifier(acc)
|
||||
return (
|
||||
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
|
||||
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
|
||||
"bg-amber-500"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
|
||||
onClick={() => copyId(id)}
|
||||
>
|
||||
<span className="truncate">{id || '-'}</span>
|
||||
{copiedId === id
|
||||
? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
|
||||
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
|
||||
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
|
||||
{acc.token_preview && (
|
||||
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
|
||||
{acc.token_preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
|
||||
<button
|
||||
onClick={() => onTestAccount(id)}
|
||||
disabled={testing[id]}
|
||||
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing[id] ? t('actions.testing') : t('actions.test')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteAccount(id)}
|
||||
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
|
||||
</div>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => onPageSizeChange(Number(e.target.value))}
|
||||
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
|
||||
>
|
||||
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrevPage}
|
||||
disabled={page <= 1 || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={page >= totalPages || loadingAccounts}
|
||||
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,68 +1,86 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useAccountsData({ apiFetch }) {
|
||||
const [queueStatus, setQueueStatus] = useState(null)
|
||||
const [keysExpanded, setKeysExpanded] = useState(false)
|
||||
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(10)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalAccounts, setTotalAccounts] = useState(0)
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false)
|
||||
|
||||
const resolveAccountIdentifier = (acc) => {
|
||||
if (!acc || typeof acc !== 'object') return ''
|
||||
return String(acc.identifier || acc.email || acc.mobile || '').trim()
|
||||
}
|
||||
|
||||
const fetchAccounts = async (targetPage = page) => {
|
||||
setLoadingAccounts(true)
|
||||
try {
|
||||
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAccounts(data.items || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotalAccounts(data.total || 0)
|
||||
setPage(data.page || 1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch accounts:', e)
|
||||
} finally {
|
||||
setLoadingAccounts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/admin/queue/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueueStatus(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch queue status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchQueueStatus()
|
||||
const interval = setInterval(fetchQueueStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
resolveAccountIdentifier,
|
||||
}
|
||||
}
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useAccountsData({ apiFetch }) {
|
||||
const [queueStatus, setQueueStatus] = useState(null)
|
||||
const [keysExpanded, setKeysExpanded] = useState(false)
|
||||
|
||||
const [accounts, setAccounts] = useState([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalAccounts, setTotalAccounts] = useState(0)
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false)
|
||||
|
||||
const resolveAccountIdentifier = (acc) => {
|
||||
if (!acc || typeof acc !== 'object') return ''
|
||||
return String(acc.identifier || acc.email || acc.mobile || '').trim()
|
||||
}
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const fetchAccounts = async (targetPage = page, targetPageSize = pageSize, targetQuery = searchQuery) => {
|
||||
setLoadingAccounts(true)
|
||||
try {
|
||||
let url = `/admin/accounts?page=${targetPage}&page_size=${targetPageSize}`
|
||||
if (targetQuery.trim()) url += `&q=${encodeURIComponent(targetQuery.trim())}`
|
||||
const res = await apiFetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAccounts(data.items || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotalAccounts(data.total || 0)
|
||||
setPage(data.page || 1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch accounts:', e)
|
||||
} finally {
|
||||
setLoadingAccounts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changePageSize = (newSize) => {
|
||||
setPageSize(newSize)
|
||||
fetchAccounts(1, newSize)
|
||||
}
|
||||
|
||||
const handleSearchChange = (query) => {
|
||||
setSearchQuery(query)
|
||||
fetchAccounts(1, pageSize, query)
|
||||
}
|
||||
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/admin/queue/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueueStatus(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch queue status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchQueueStatus()
|
||||
const interval = setInterval(fetchQueueStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
queueStatus,
|
||||
keysExpanded,
|
||||
setKeysExpanded,
|
||||
accounts,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalAccounts,
|
||||
loadingAccounts,
|
||||
fetchAccounts,
|
||||
changePageSize,
|
||||
resolveAccountIdentifier,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,294 +1,297 @@
|
||||
{
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"english": "English",
|
||||
"chinese": "中文"
|
||||
},
|
||||
"nav": {
|
||||
"accounts": {
|
||||
"label": "Account Management",
|
||||
"desc": "Manage the DeepSeek account pool"
|
||||
},
|
||||
"test": {
|
||||
"label": "API Test",
|
||||
"desc": "Test API connectivity and responses"
|
||||
},
|
||||
"import": {
|
||||
"label": "Batch Import",
|
||||
"desc": "Bulk import account configuration"
|
||||
},
|
||||
"vercel": {
|
||||
"label": "Vercel Sync",
|
||||
"desc": "Sync configuration to Vercel"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Settings",
|
||||
"desc": "Edit runtime and security settings online"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"onlineAdminConsole": "Online Admin Console",
|
||||
"systemStatus": "System Status",
|
||||
"statusOnline": "Online",
|
||||
"accounts": "Accounts",
|
||||
"keys": "Keys",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"auth": {
|
||||
"expired": "Authentication expired. Please sign in again.",
|
||||
"checking": "Checking authentication status..."
|
||||
},
|
||||
"errors": {
|
||||
"fetchConfig": "Failed to fetch configuration: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"add": "Add",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"generate": "Generate",
|
||||
"test": "Test",
|
||||
"testing": "Testing...",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"messages": {
|
||||
"deleted": "Deleted successfully",
|
||||
"deleteFailed": "Delete failed",
|
||||
"failedToAdd": "Failed to add",
|
||||
"networkError": "Network error.",
|
||||
"requestFailed": "Request failed.",
|
||||
"generationStopped": "Generation stopped.",
|
||||
"invalidJson": "Invalid JSON format.",
|
||||
"importFailed": "Import failed.",
|
||||
"copyFailed": "Copy failed."
|
||||
},
|
||||
"landing": {
|
||||
"adminConsole": "Admin Console",
|
||||
"apiStatus": "API Status",
|
||||
"features": {
|
||||
"compatibility": {
|
||||
"title": "Full Compatibility",
|
||||
"desc": "OpenAI & Claude format support"
|
||||
},
|
||||
"loadBalancing": {
|
||||
"title": "Load Balancing",
|
||||
"desc": "Smart rotation with stable throughput"
|
||||
},
|
||||
"reasoning": {
|
||||
"title": "Deep Reasoning",
|
||||
"desc": "Expose reasoning traces when enabled"
|
||||
},
|
||||
"search": {
|
||||
"title": "Web Search",
|
||||
"desc": "Integrated native web search"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API key added successfully.",
|
||||
"addAccountSuccess": "Account added successfully.",
|
||||
"requiredFields": "Password and email/mobile are required.",
|
||||
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
|
||||
"deleteAccountConfirm": "Are you sure you want to delete this account?",
|
||||
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
|
||||
"testAllConfirm": "Test API connectivity for all accounts?",
|
||||
"testAllCompleted": "Completed: {success}/{total} available",
|
||||
"testFailed": "Test failed: {error}",
|
||||
"available": "Available",
|
||||
"inUse": "In use",
|
||||
"totalPool": "Total pool",
|
||||
"accountsUnit": "accounts",
|
||||
"threadsUnit": "threads",
|
||||
"apiKeysTitle": "API Keys",
|
||||
"apiKeysDesc": "Manage the API access key pool",
|
||||
"addKey": "Add key",
|
||||
"copied": "Copied",
|
||||
"copyKeyTitle": "Copy key",
|
||||
"deleteKeyTitle": "Delete key",
|
||||
"noApiKeys": "No API keys found.",
|
||||
"accountsTitle": "DeepSeek Accounts",
|
||||
"accountsDesc": "Manage the DeepSeek account pool",
|
||||
"testAll": "Test all",
|
||||
"addAccount": "Add account",
|
||||
"testingAllAccounts": "Testing all accounts...",
|
||||
"sessionActive": "Session active",
|
||||
"reauthRequired": "Re-auth required",
|
||||
"noAccounts": "No accounts found.",
|
||||
"modalAddKeyTitle": "Add API key",
|
||||
"newKeyLabel": "New key value",
|
||||
"newKeyPlaceholder": "Enter a custom API key",
|
||||
"generate": "Generate",
|
||||
"generateHint": "Click Generate to create a random key.",
|
||||
"addKeyLoading": "Adding...",
|
||||
"addKeyAction": "Add key",
|
||||
"modalAddAccountTitle": "Add DeepSeek account",
|
||||
"emailOptional": "Email (optional)",
|
||||
"mobileOptional": "Mobile (optional)",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Account password",
|
||||
"addAccountLoading": "Adding...",
|
||||
"addAccountAction": "Add account",
|
||||
"pageInfo": "Page {current}/{total}, {count} accounts total"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||
"models": {
|
||||
"chat": "Non-reasoning model",
|
||||
"reasoner": "Reasoning model",
|
||||
"chatSearch": "Non-reasoning model (with search)",
|
||||
"reasonerSearch": "Reasoning model (with search)"
|
||||
},
|
||||
"missingApiKey": "Please provide an API key.",
|
||||
"requestFailed": "Request failed.",
|
||||
"networkError": "Network error: {error}",
|
||||
"testSuccess": "{account}: Test successful ({time}ms)",
|
||||
"config": "Configuration",
|
||||
"modelLabel": "Model",
|
||||
"streamMode": "Streaming",
|
||||
"accountSelector": "Account",
|
||||
"autoRandom": "🤖 Auto / Random",
|
||||
"apiKeyOptional": "API Key (optional)",
|
||||
"apiKeyDefault": "Default: ...{suffix}",
|
||||
"apiKeyPlaceholder": "Enter a custom key",
|
||||
"modeManaged": "Managed key mode (uses account pool).",
|
||||
"modeDirect": "Direct token mode (requires a valid DeepSeek token).",
|
||||
"statusError": "Error",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
"generating": "Generating response...",
|
||||
"enterMessage": "Enter a message...",
|
||||
"adminConsoleLabel": "DeepSeek admin console"
|
||||
},
|
||||
"batchImport": {
|
||||
"templates": {
|
||||
"full": {
|
||||
"name": "Full configuration template",
|
||||
"desc": "Includes keys, accounts, and model mapping"
|
||||
},
|
||||
"emailOnly": {
|
||||
"name": "Email-only accounts",
|
||||
"desc": "Batch import accounts using email login"
|
||||
},
|
||||
"mobileOnly": {
|
||||
"name": "Mobile-only accounts",
|
||||
"desc": "Batch import accounts using mobile login"
|
||||
},
|
||||
"keysOnly": {
|
||||
"name": "API keys only",
|
||||
"desc": "Add API access keys only"
|
||||
}
|
||||
},
|
||||
"enterJson": "Please provide JSON configuration content.",
|
||||
"importSuccess": "Import successful: {keys} keys, {accounts} accounts",
|
||||
"templateLoaded": "Template loaded: {name}",
|
||||
"currentConfigLoaded": "Current configuration loaded.",
|
||||
"fetchConfigFailed": "Failed to fetch configuration.",
|
||||
"copySuccess": "Base64 configuration copied to clipboard.",
|
||||
"quickTemplates": "Quick Templates",
|
||||
"dataExport": "Data Export",
|
||||
"dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.",
|
||||
"copyBase64": "Copy Base64 config",
|
||||
"copied": "Copied",
|
||||
"variableName": "Variable name",
|
||||
"jsonEditor": "JSON Editor",
|
||||
"loadCurrentConfig": "Load current config",
|
||||
"applyConfig": "Apply config",
|
||||
"importing": "Importing...",
|
||||
"importComplete": "Import complete",
|
||||
"importSummary": "Imported {keys} API keys and updated {accounts} accounts."
|
||||
},
|
||||
"settings": {
|
||||
"loadFailed": "Failed to load settings.",
|
||||
"nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).",
|
||||
"save": "Save settings",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Settings saved and hot reloaded.",
|
||||
"saveFailed": "Failed to save settings.",
|
||||
"securityTitle": "Security",
|
||||
"jwtExpireHours": "JWT expiry (hours)",
|
||||
"newPassword": "New admin password",
|
||||
"newPasswordPlaceholder": "Enter new password (min 4 chars)",
|
||||
"updatePassword": "Update password",
|
||||
"updating": "Updating...",
|
||||
"passwordTooShort": "Password must be at least 4 characters.",
|
||||
"passwordUpdated": "Password updated. Please sign in again.",
|
||||
"passwordUpdateFailed": "Failed to update password.",
|
||||
"runtimeTitle": "Concurrency & Queue",
|
||||
"accountMaxInflight": "Per-account max inflight",
|
||||
"accountMaxQueue": "Account max queue size",
|
||||
"globalMaxInflight": "Global max inflight",
|
||||
"behaviorTitle": "Behavior",
|
||||
"toolcallMode": "Toolcall mode",
|
||||
"earlyEmitConfidence": "Early emit confidence",
|
||||
"responsesTTL": "Responses store TTL (seconds)",
|
||||
"embeddingsProvider": "Embeddings provider",
|
||||
"modelTitle": "Model mapping",
|
||||
"claudeMapping": "Claude mapping (JSON)",
|
||||
"modelAliases": "Model aliases (JSON)",
|
||||
"backupTitle": "Backup & Restore",
|
||||
"loadExport": "Load current export",
|
||||
"importModeMerge": "Merge import (default)",
|
||||
"importModeReplace": "Replace all import",
|
||||
"importNow": "Import now",
|
||||
"importing": "Importing...",
|
||||
"importPlaceholder": "Paste config JSON to import",
|
||||
"importEmpty": "Please input import JSON.",
|
||||
"importInvalidJson": "Import JSON is invalid.",
|
||||
"importFailed": "Import failed.",
|
||||
"importSuccess": "Config imported (mode: {mode}).",
|
||||
"exportFailed": "Export failed.",
|
||||
"exportLoaded": "Current export loaded.",
|
||||
"exportJson": "Export JSON",
|
||||
"invalidJsonField": "{field} is not a valid JSON object.",
|
||||
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
|
||||
"vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.",
|
||||
"autoFetchPaused": "Auto loading paused after {count} failures: {error}",
|
||||
"retryLoad": "Retry now"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Welcome back",
|
||||
"subtitle": "Enter your admin key to continue",
|
||||
"adminKeyLabel": "Admin key",
|
||||
"adminKeyPlaceholder": "Enter your admin key...",
|
||||
"rememberSession": "Remember this session",
|
||||
"signIn": "Sign in",
|
||||
"secureConnection": "Secure connection",
|
||||
"adminPortal": "DS2API admin portal",
|
||||
"signInFailed": "Sign-in failed.",
|
||||
"networkError": "Network error: {error}"
|
||||
},
|
||||
"vercel": {
|
||||
"tokenRequired": "Vercel access token is required.",
|
||||
"projectRequired": "Project ID is required.",
|
||||
"syncFailed": "Sync failed.",
|
||||
"networkError": "Network error.",
|
||||
"title": "Vercel Deployment",
|
||||
"description": "Sync the current keys and accounts directly to Vercel environment variables.",
|
||||
"tokenLabel": "Vercel Access Token",
|
||||
"getToken": "Get token",
|
||||
"tokenPlaceholderPreconfig": "Using preconfigured token",
|
||||
"tokenPlaceholder": "Enter Vercel access token",
|
||||
"projectIdLabel": "Project ID",
|
||||
"projectIdHint": "Find it in Project Settings → General.",
|
||||
"teamIdLabel": "Team ID",
|
||||
"optional": "optional",
|
||||
"syncing": "Syncing...",
|
||||
"syncRedeploy": "Sync & redeploy",
|
||||
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
||||
"syncSucceeded": "Sync succeeded",
|
||||
"syncFailedLabel": "Sync failed",
|
||||
"openDeployment": "Open deployment",
|
||||
"statusSynced": "Synced",
|
||||
"statusNotSynced": "Not synced",
|
||||
"statusNeverSynced": "Never synced",
|
||||
"lastSyncTime": "Last sync: {time}",
|
||||
"pollPaused": "Status polling paused after {count} failures.",
|
||||
"manualRefresh": "Refresh manually",
|
||||
"howItWorks": "How it works",
|
||||
"steps": {
|
||||
"one": "The current configuration (keys and accounts) is exported as JSON.",
|
||||
"two": "The JSON is Base64-encoded for safe formatting.",
|
||||
"three": "Update the env var in Vercel:",
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"english": "English",
|
||||
"chinese": "中文"
|
||||
},
|
||||
"nav": {
|
||||
"accounts": {
|
||||
"label": "Account Management",
|
||||
"desc": "Manage the DeepSeek account pool"
|
||||
},
|
||||
"test": {
|
||||
"label": "API Test",
|
||||
"desc": "Test API connectivity and responses"
|
||||
},
|
||||
"import": {
|
||||
"label": "Batch Import",
|
||||
"desc": "Bulk import account configuration"
|
||||
},
|
||||
"vercel": {
|
||||
"label": "Vercel Sync",
|
||||
"desc": "Sync configuration to Vercel"
|
||||
},
|
||||
"settings": {
|
||||
"label": "Settings",
|
||||
"desc": "Edit runtime and security settings online"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"onlineAdminConsole": "Online Admin Console",
|
||||
"systemStatus": "System Status",
|
||||
"statusOnline": "Online",
|
||||
"accounts": "Accounts",
|
||||
"keys": "Keys",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"auth": {
|
||||
"expired": "Authentication expired. Please sign in again.",
|
||||
"checking": "Checking authentication status..."
|
||||
},
|
||||
"errors": {
|
||||
"fetchConfig": "Failed to fetch configuration: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"add": "Add",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"generate": "Generate",
|
||||
"test": "Test",
|
||||
"testing": "Testing...",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"messages": {
|
||||
"deleted": "Deleted successfully",
|
||||
"deleteFailed": "Delete failed",
|
||||
"failedToAdd": "Failed to add",
|
||||
"networkError": "Network error.",
|
||||
"requestFailed": "Request failed.",
|
||||
"generationStopped": "Generation stopped.",
|
||||
"invalidJson": "Invalid JSON format.",
|
||||
"importFailed": "Import failed.",
|
||||
"copyFailed": "Copy failed."
|
||||
},
|
||||
"landing": {
|
||||
"adminConsole": "Admin Console",
|
||||
"apiStatus": "API Status",
|
||||
"features": {
|
||||
"compatibility": {
|
||||
"title": "Full Compatibility",
|
||||
"desc": "OpenAI & Claude format support"
|
||||
},
|
||||
"loadBalancing": {
|
||||
"title": "Load Balancing",
|
||||
"desc": "Smart rotation with stable throughput"
|
||||
},
|
||||
"reasoning": {
|
||||
"title": "Deep Reasoning",
|
||||
"desc": "Expose reasoning traces when enabled"
|
||||
},
|
||||
"search": {
|
||||
"title": "Web Search",
|
||||
"desc": "Integrated native web search"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API key added successfully.",
|
||||
"addAccountSuccess": "Account added successfully.",
|
||||
"requiredFields": "Password and email/mobile are required.",
|
||||
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
|
||||
"deleteAccountConfirm": "Are you sure you want to delete this account?",
|
||||
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
|
||||
"testAllConfirm": "Test API connectivity for all accounts?",
|
||||
"testAllCompleted": "Completed: {success}/{total} available",
|
||||
"testFailed": "Test failed: {error}",
|
||||
"available": "Available",
|
||||
"inUse": "In use",
|
||||
"totalPool": "Total pool",
|
||||
"accountsUnit": "accounts",
|
||||
"threadsUnit": "threads",
|
||||
"apiKeysTitle": "API Keys",
|
||||
"apiKeysDesc": "Manage the API access key pool",
|
||||
"addKey": "Add key",
|
||||
"copied": "Copied",
|
||||
"copyKeyTitle": "Copy key",
|
||||
"deleteKeyTitle": "Delete key",
|
||||
"noApiKeys": "No API keys found.",
|
||||
"accountsTitle": "DeepSeek Accounts",
|
||||
"accountsDesc": "Manage the DeepSeek account pool",
|
||||
"testAll": "Test all",
|
||||
"addAccount": "Add account",
|
||||
"testingAllAccounts": "Testing all accounts...",
|
||||
"sessionActive": "Session active",
|
||||
"reauthRequired": "Re-auth required",
|
||||
"testStatusFailed": "Last test failed",
|
||||
"noAccounts": "No accounts found.",
|
||||
"modalAddKeyTitle": "Add API key",
|
||||
"newKeyLabel": "New key value",
|
||||
"newKeyPlaceholder": "Enter a custom API key",
|
||||
"generate": "Generate",
|
||||
"generateHint": "Click Generate to create a random key.",
|
||||
"addKeyLoading": "Adding...",
|
||||
"addKeyAction": "Add key",
|
||||
"modalAddAccountTitle": "Add DeepSeek account",
|
||||
"emailOptional": "Email (optional)",
|
||||
"mobileOptional": "Mobile (optional)",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Account password",
|
||||
"addAccountLoading": "Adding...",
|
||||
"addAccountAction": "Add account",
|
||||
"pageInfo": "Page {current}/{total}, {count} accounts total",
|
||||
"searchPlaceholder": "Search accounts...",
|
||||
"searchNoResults": "No accounts match your search"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||
"models": {
|
||||
"chat": "Non-reasoning model",
|
||||
"reasoner": "Reasoning model",
|
||||
"chatSearch": "Non-reasoning model (with search)",
|
||||
"reasonerSearch": "Reasoning model (with search)"
|
||||
},
|
||||
"missingApiKey": "Please provide an API key.",
|
||||
"requestFailed": "Request failed.",
|
||||
"networkError": "Network error: {error}",
|
||||
"testSuccess": "{account}: Test successful ({time}ms)",
|
||||
"config": "Configuration",
|
||||
"modelLabel": "Model",
|
||||
"streamMode": "Streaming",
|
||||
"accountSelector": "Account",
|
||||
"autoRandom": "🤖 Auto / Random",
|
||||
"apiKeyOptional": "API Key (optional)",
|
||||
"apiKeyDefault": "Default: ...{suffix}",
|
||||
"apiKeyPlaceholder": "Enter a custom key",
|
||||
"modeManaged": "Managed key mode (uses account pool).",
|
||||
"modeDirect": "Direct token mode (requires a valid DeepSeek token).",
|
||||
"statusError": "Error",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
"generating": "Generating response...",
|
||||
"enterMessage": "Enter a message...",
|
||||
"adminConsoleLabel": "DeepSeek admin console"
|
||||
},
|
||||
"batchImport": {
|
||||
"templates": {
|
||||
"full": {
|
||||
"name": "Full configuration template",
|
||||
"desc": "Includes keys, accounts, and model mapping"
|
||||
},
|
||||
"emailOnly": {
|
||||
"name": "Email-only accounts",
|
||||
"desc": "Batch import accounts using email login"
|
||||
},
|
||||
"mobileOnly": {
|
||||
"name": "Mobile-only accounts",
|
||||
"desc": "Batch import accounts using mobile login"
|
||||
},
|
||||
"keysOnly": {
|
||||
"name": "API keys only",
|
||||
"desc": "Add API access keys only"
|
||||
}
|
||||
},
|
||||
"enterJson": "Please provide JSON configuration content.",
|
||||
"importSuccess": "Import successful: {keys} keys, {accounts} accounts",
|
||||
"templateLoaded": "Template loaded: {name}",
|
||||
"currentConfigLoaded": "Current configuration loaded.",
|
||||
"fetchConfigFailed": "Failed to fetch configuration.",
|
||||
"copySuccess": "Base64 configuration copied to clipboard.",
|
||||
"quickTemplates": "Quick Templates",
|
||||
"dataExport": "Data Export",
|
||||
"dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.",
|
||||
"copyBase64": "Copy Base64 config",
|
||||
"copied": "Copied",
|
||||
"variableName": "Variable name",
|
||||
"jsonEditor": "JSON Editor",
|
||||
"loadCurrentConfig": "Load current config",
|
||||
"applyConfig": "Apply config",
|
||||
"importing": "Importing...",
|
||||
"importComplete": "Import complete",
|
||||
"importSummary": "Imported {keys} API keys and updated {accounts} accounts."
|
||||
},
|
||||
"settings": {
|
||||
"loadFailed": "Failed to load settings.",
|
||||
"nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).",
|
||||
"save": "Save settings",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Settings saved and hot reloaded.",
|
||||
"saveFailed": "Failed to save settings.",
|
||||
"securityTitle": "Security",
|
||||
"jwtExpireHours": "JWT expiry (hours)",
|
||||
"newPassword": "New admin password",
|
||||
"newPasswordPlaceholder": "Enter new password (min 4 chars)",
|
||||
"updatePassword": "Update password",
|
||||
"updating": "Updating...",
|
||||
"passwordTooShort": "Password must be at least 4 characters.",
|
||||
"passwordUpdated": "Password updated. Please sign in again.",
|
||||
"passwordUpdateFailed": "Failed to update password.",
|
||||
"runtimeTitle": "Concurrency & Queue",
|
||||
"accountMaxInflight": "Per-account max inflight",
|
||||
"accountMaxQueue": "Account max queue size",
|
||||
"globalMaxInflight": "Global max inflight",
|
||||
"behaviorTitle": "Behavior",
|
||||
"toolcallMode": "Toolcall mode",
|
||||
"earlyEmitConfidence": "Early emit confidence",
|
||||
"responsesTTL": "Responses store TTL (seconds)",
|
||||
"embeddingsProvider": "Embeddings provider",
|
||||
"modelTitle": "Model mapping",
|
||||
"claudeMapping": "Claude mapping (JSON)",
|
||||
"modelAliases": "Model aliases (JSON)",
|
||||
"backupTitle": "Backup & Restore",
|
||||
"loadExport": "Load current export",
|
||||
"importModeMerge": "Merge import (default)",
|
||||
"importModeReplace": "Replace all import",
|
||||
"importNow": "Import now",
|
||||
"importing": "Importing...",
|
||||
"importPlaceholder": "Paste config JSON to import",
|
||||
"importEmpty": "Please input import JSON.",
|
||||
"importInvalidJson": "Import JSON is invalid.",
|
||||
"importFailed": "Import failed.",
|
||||
"importSuccess": "Config imported (mode: {mode}).",
|
||||
"exportFailed": "Export failed.",
|
||||
"exportLoaded": "Current export loaded.",
|
||||
"exportJson": "Export JSON",
|
||||
"invalidJsonField": "{field} is not a valid JSON object.",
|
||||
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
|
||||
"vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.",
|
||||
"autoFetchPaused": "Auto loading paused after {count} failures: {error}",
|
||||
"retryLoad": "Retry now"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "Welcome back",
|
||||
"subtitle": "Enter your admin key to continue",
|
||||
"adminKeyLabel": "Admin key",
|
||||
"adminKeyPlaceholder": "Enter your admin key...",
|
||||
"rememberSession": "Remember this session",
|
||||
"signIn": "Sign in",
|
||||
"secureConnection": "Secure connection",
|
||||
"adminPortal": "DS2API admin portal",
|
||||
"signInFailed": "Sign-in failed.",
|
||||
"networkError": "Network error: {error}"
|
||||
},
|
||||
"vercel": {
|
||||
"tokenRequired": "Vercel access token is required.",
|
||||
"projectRequired": "Project ID is required.",
|
||||
"syncFailed": "Sync failed.",
|
||||
"networkError": "Network error.",
|
||||
"title": "Vercel Deployment",
|
||||
"description": "Sync the current keys and accounts directly to Vercel environment variables.",
|
||||
"tokenLabel": "Vercel Access Token",
|
||||
"getToken": "Get token",
|
||||
"tokenPlaceholderPreconfig": "Using preconfigured token",
|
||||
"tokenPlaceholder": "Enter Vercel access token",
|
||||
"projectIdLabel": "Project ID",
|
||||
"projectIdHint": "Find it in Project Settings → General.",
|
||||
"teamIdLabel": "Team ID",
|
||||
"optional": "optional",
|
||||
"syncing": "Syncing...",
|
||||
"syncRedeploy": "Sync & redeploy",
|
||||
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
||||
"syncSucceeded": "Sync succeeded",
|
||||
"syncFailedLabel": "Sync failed",
|
||||
"openDeployment": "Open deployment",
|
||||
"statusSynced": "Synced",
|
||||
"statusNotSynced": "Not synced",
|
||||
"statusNeverSynced": "Never synced",
|
||||
"lastSyncTime": "Last sync: {time}",
|
||||
"pollPaused": "Status polling paused after {count} failures.",
|
||||
"manualRefresh": "Refresh manually",
|
||||
"howItWorks": "How it works",
|
||||
"steps": {
|
||||
"one": "The current configuration (keys and accounts) is exported as JSON.",
|
||||
"two": "The JSON is Base64-encoded for safe formatting.",
|
||||
"three": "Update the env var in Vercel:",
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,294 +1,297 @@
|
||||
{
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"english": "English",
|
||||
"chinese": "中文"
|
||||
},
|
||||
"nav": {
|
||||
"accounts": {
|
||||
"label": "账号管理",
|
||||
"desc": "管理 DeepSeek 账号池"
|
||||
},
|
||||
"test": {
|
||||
"label": "API 测试",
|
||||
"desc": "测试 API 连接与响应"
|
||||
},
|
||||
"import": {
|
||||
"label": "批量导入",
|
||||
"desc": "批量导入账号配置"
|
||||
},
|
||||
"vercel": {
|
||||
"label": "Vercel 同步",
|
||||
"desc": "同步配置到 Vercel"
|
||||
},
|
||||
"settings": {
|
||||
"label": "设置中心",
|
||||
"desc": "在线修改系统设置与配置"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"onlineAdminConsole": "在线管理面板",
|
||||
"systemStatus": "系统状态",
|
||||
"statusOnline": "在线",
|
||||
"accounts": "账号",
|
||||
"keys": "密钥",
|
||||
"signOut": "退出登录"
|
||||
},
|
||||
"auth": {
|
||||
"expired": "认证已过期,请重新登录",
|
||||
"checking": "正在检查登录状态..."
|
||||
},
|
||||
"errors": {
|
||||
"fetchConfig": "获取配置失败: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"generate": "生成",
|
||||
"test": "测试",
|
||||
"testing": "正在测试...",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"messages": {
|
||||
"deleted": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"failedToAdd": "添加失败",
|
||||
"networkError": "网络错误",
|
||||
"requestFailed": "请求失败",
|
||||
"generationStopped": "已停止生成",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"importFailed": "导入失败",
|
||||
"copyFailed": "复制失败"
|
||||
},
|
||||
"landing": {
|
||||
"adminConsole": "管理面板",
|
||||
"apiStatus": "API 状态",
|
||||
"features": {
|
||||
"compatibility": {
|
||||
"title": "全面兼容",
|
||||
"desc": "适配 OpenAI 与 Claude 格式"
|
||||
},
|
||||
"loadBalancing": {
|
||||
"title": "负载均衡",
|
||||
"desc": "智能轮询,稳定高效"
|
||||
},
|
||||
"reasoning": {
|
||||
"title": "深度思考",
|
||||
"desc": "支持推理过程输出"
|
||||
},
|
||||
"search": {
|
||||
"title": "联网搜索",
|
||||
"desc": "集成原生网页搜索能力"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API 密钥添加成功",
|
||||
"addAccountSuccess": "账号添加成功",
|
||||
"requiredFields": "需要填写密码以及邮箱或手机号",
|
||||
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
|
||||
"deleteAccountConfirm": "确定要删除此账号吗?",
|
||||
"invalidIdentifier": "账号标识无效,无法执行操作",
|
||||
"testAllConfirm": "测试所有账号的 API 连通性?",
|
||||
"testAllCompleted": "完成:{success}/{total} 可用",
|
||||
"testFailed": "测试失败: {error}",
|
||||
"available": "可用",
|
||||
"inUse": "正在使用",
|
||||
"totalPool": "账号池总数",
|
||||
"accountsUnit": "个账号",
|
||||
"threadsUnit": "线程",
|
||||
"apiKeysTitle": "API 密钥",
|
||||
"apiKeysDesc": "管理 API 访问密钥池",
|
||||
"addKey": "添加密钥",
|
||||
"copied": "已复制",
|
||||
"copyKeyTitle": "复制密钥",
|
||||
"deleteKeyTitle": "删除密钥",
|
||||
"noApiKeys": "未找到 API 密钥",
|
||||
"accountsTitle": "DeepSeek 账号",
|
||||
"accountsDesc": "管理 DeepSeek 账号池",
|
||||
"testAll": "测试全部",
|
||||
"addAccount": "添加账号",
|
||||
"testingAllAccounts": "正在测试所有账号...",
|
||||
"sessionActive": "已建立会话",
|
||||
"reauthRequired": "需重新登录",
|
||||
"noAccounts": "未找到任何账号",
|
||||
"modalAddKeyTitle": "添加 API 密钥",
|
||||
"newKeyLabel": "新密钥值",
|
||||
"newKeyPlaceholder": "输入自定义 API 密钥",
|
||||
"generate": "生成",
|
||||
"generateHint": "点击「生成」自动创建随机密钥",
|
||||
"addKeyLoading": "添加中...",
|
||||
"addKeyAction": "添加密钥",
|
||||
"modalAddAccountTitle": "添加 DeepSeek 账号",
|
||||
"emailOptional": "邮箱 (可选)",
|
||||
"mobileOptional": "手机号 (可选)",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "账号密码",
|
||||
"addAccountLoading": "添加中...",
|
||||
"addAccountAction": "添加账号",
|
||||
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||
"models": {
|
||||
"chat": "非思考模型",
|
||||
"reasoner": "思考模型",
|
||||
"chatSearch": "非思考模型 (带搜索)",
|
||||
"reasonerSearch": "思考模型 (带搜索)"
|
||||
},
|
||||
"missingApiKey": "请提供 API 密钥",
|
||||
"requestFailed": "请求失败",
|
||||
"networkError": "网络错误: {error}",
|
||||
"testSuccess": "{account}: 测试成功 ({time}ms)",
|
||||
"config": "配置",
|
||||
"modelLabel": "模型",
|
||||
"streamMode": "流式模式",
|
||||
"accountSelector": "选择账号",
|
||||
"autoRandom": "🤖 自动 / 随机",
|
||||
"apiKeyOptional": "API 密钥 (可选)",
|
||||
"apiKeyDefault": "默认: ...{suffix}",
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
"statusError": "错误",
|
||||
"reasoningTrace": "思维链过程",
|
||||
"generating": "正在生成响应...",
|
||||
"enterMessage": "输入消息...",
|
||||
"adminConsoleLabel": "DeepSeek 管理员界面"
|
||||
},
|
||||
"batchImport": {
|
||||
"templates": {
|
||||
"full": {
|
||||
"name": "全量配置模板",
|
||||
"desc": "包含密钥、账号及模型映射"
|
||||
},
|
||||
"emailOnly": {
|
||||
"name": "仅邮箱账号",
|
||||
"desc": "批量导入邮箱格式账号"
|
||||
},
|
||||
"mobileOnly": {
|
||||
"name": "仅手机号账号",
|
||||
"desc": "批量导入手机号格式账号"
|
||||
},
|
||||
"keysOnly": {
|
||||
"name": "仅 API 密钥",
|
||||
"desc": "仅添加 API 访问密钥"
|
||||
}
|
||||
},
|
||||
"enterJson": "请输入 JSON 配置内容",
|
||||
"importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号",
|
||||
"templateLoaded": "已加载模板: {name}",
|
||||
"currentConfigLoaded": "当前配置已加载",
|
||||
"fetchConfigFailed": "获取配置失败",
|
||||
"copySuccess": "Base64 配置已复制到剪贴板",
|
||||
"quickTemplates": "快速模板",
|
||||
"dataExport": "数据导出",
|
||||
"dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。",
|
||||
"copyBase64": "复制 Base64 配置",
|
||||
"copied": "已复制",
|
||||
"variableName": "变量名",
|
||||
"jsonEditor": "JSON 编辑器",
|
||||
"loadCurrentConfig": "加载当前配置",
|
||||
"applyConfig": "应用配置",
|
||||
"importing": "正在导入...",
|
||||
"importComplete": "导入操作已完成",
|
||||
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
|
||||
},
|
||||
"settings": {
|
||||
"loadFailed": "加载设置失败",
|
||||
"nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})",
|
||||
"save": "保存设置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "设置已保存并热更新生效",
|
||||
"saveFailed": "保存设置失败",
|
||||
"securityTitle": "安全设置",
|
||||
"jwtExpireHours": "JWT 有效期(小时)",
|
||||
"newPassword": "面板新密码",
|
||||
"newPasswordPlaceholder": "输入新密码(至少 4 位)",
|
||||
"updatePassword": "修改密码",
|
||||
"updating": "更新中...",
|
||||
"passwordTooShort": "新密码至少 4 位",
|
||||
"passwordUpdated": "密码已更新,需重新登录",
|
||||
"passwordUpdateFailed": "密码更新失败",
|
||||
"runtimeTitle": "并发与队列",
|
||||
"accountMaxInflight": "每账号并发上限",
|
||||
"accountMaxQueue": "账号等待队列上限",
|
||||
"globalMaxInflight": "全局并发上限",
|
||||
"behaviorTitle": "行为设置",
|
||||
"toolcallMode": "Toolcall 模式",
|
||||
"earlyEmitConfidence": "早发置信度",
|
||||
"responsesTTL": "Responses 缓存 TTL(秒)",
|
||||
"embeddingsProvider": "Embeddings Provider",
|
||||
"modelTitle": "模型映射",
|
||||
"claudeMapping": "Claude 映射(JSON)",
|
||||
"modelAliases": "模型别名(JSON)",
|
||||
"backupTitle": "备份与恢复",
|
||||
"loadExport": "加载当前导出",
|
||||
"importModeMerge": "合并导入(默认)",
|
||||
"importModeReplace": "全量覆盖导入",
|
||||
"importNow": "立即导入",
|
||||
"importing": "导入中...",
|
||||
"importPlaceholder": "粘贴要导入的 JSON 配置",
|
||||
"importEmpty": "请先输入导入 JSON",
|
||||
"importInvalidJson": "导入 JSON 格式无效",
|
||||
"importFailed": "导入失败",
|
||||
"importSuccess": "配置导入成功(模式:{mode})",
|
||||
"exportFailed": "导出失败",
|
||||
"exportLoaded": "已加载当前配置导出",
|
||||
"exportJson": "导出 JSON",
|
||||
"invalidJsonField": "{field} 不是有效 JSON 对象",
|
||||
"defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。",
|
||||
"vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。",
|
||||
"autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})",
|
||||
"retryLoad": "立即重试"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "欢迎回来",
|
||||
"subtitle": "请输入管理员密钥以继续",
|
||||
"adminKeyLabel": "管理员密钥",
|
||||
"adminKeyPlaceholder": "输入您的管理员密钥...",
|
||||
"rememberSession": "记住登录状态",
|
||||
"signIn": "登录",
|
||||
"secureConnection": "安全连接",
|
||||
"adminPortal": "DS2API 管理员门户",
|
||||
"signInFailed": "登录失败",
|
||||
"networkError": "网络错误: {error}"
|
||||
},
|
||||
"vercel": {
|
||||
"tokenRequired": "需要 Vercel 访问令牌",
|
||||
"projectRequired": "需要项目 ID",
|
||||
"syncFailed": "同步失败",
|
||||
"networkError": "网络错误",
|
||||
"title": "Vercel 部署",
|
||||
"description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。",
|
||||
"tokenLabel": "Vercel 访问令牌",
|
||||
"getToken": "获取令牌",
|
||||
"tokenPlaceholderPreconfig": "正在使用预配置的令牌",
|
||||
"tokenPlaceholder": "输入 Vercel 访问令牌",
|
||||
"projectIdLabel": "项目 ID",
|
||||
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
||||
"teamIdLabel": "团队 ID",
|
||||
"optional": "可选",
|
||||
"syncing": "正在同步...",
|
||||
"syncRedeploy": "同步并重新部署",
|
||||
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
||||
"syncSucceeded": "同步成功",
|
||||
"syncFailedLabel": "同步失败",
|
||||
"openDeployment": "访问部署地址",
|
||||
"statusSynced": "已同步",
|
||||
"statusNotSynced": "未同步",
|
||||
"statusNeverSynced": "从未同步",
|
||||
"lastSyncTime": "上次同步: {time}",
|
||||
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
|
||||
"manualRefresh": "手动刷新",
|
||||
"howItWorks": "工作原理",
|
||||
"steps": {
|
||||
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
|
||||
"two": "JSON 被编码为 Base64 以确保格式兼容性。",
|
||||
"three": "更新 Vercel 项目中的环境变量:",
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"english": "English",
|
||||
"chinese": "中文"
|
||||
},
|
||||
"nav": {
|
||||
"accounts": {
|
||||
"label": "账号管理",
|
||||
"desc": "管理 DeepSeek 账号池"
|
||||
},
|
||||
"test": {
|
||||
"label": "API 测试",
|
||||
"desc": "测试 API 连接与响应"
|
||||
},
|
||||
"import": {
|
||||
"label": "批量导入",
|
||||
"desc": "批量导入账号配置"
|
||||
},
|
||||
"vercel": {
|
||||
"label": "Vercel 同步",
|
||||
"desc": "同步配置到 Vercel"
|
||||
},
|
||||
"settings": {
|
||||
"label": "设置中心",
|
||||
"desc": "在线修改系统设置与配置"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"onlineAdminConsole": "在线管理面板",
|
||||
"systemStatus": "系统状态",
|
||||
"statusOnline": "在线",
|
||||
"accounts": "账号",
|
||||
"keys": "密钥",
|
||||
"signOut": "退出登录"
|
||||
},
|
||||
"auth": {
|
||||
"expired": "认证已过期,请重新登录",
|
||||
"checking": "正在检查登录状态..."
|
||||
},
|
||||
"errors": {
|
||||
"fetchConfig": "获取配置失败: {error}"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"add": "添加",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"generate": "生成",
|
||||
"test": "测试",
|
||||
"testing": "正在测试...",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"messages": {
|
||||
"deleted": "删除成功",
|
||||
"deleteFailed": "删除失败",
|
||||
"failedToAdd": "添加失败",
|
||||
"networkError": "网络错误",
|
||||
"requestFailed": "请求失败",
|
||||
"generationStopped": "已停止生成",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"importFailed": "导入失败",
|
||||
"copyFailed": "复制失败"
|
||||
},
|
||||
"landing": {
|
||||
"adminConsole": "管理面板",
|
||||
"apiStatus": "API 状态",
|
||||
"features": {
|
||||
"compatibility": {
|
||||
"title": "全面兼容",
|
||||
"desc": "适配 OpenAI 与 Claude 格式"
|
||||
},
|
||||
"loadBalancing": {
|
||||
"title": "负载均衡",
|
||||
"desc": "智能轮询,稳定高效"
|
||||
},
|
||||
"reasoning": {
|
||||
"title": "深度思考",
|
||||
"desc": "支持推理过程输出"
|
||||
},
|
||||
"search": {
|
||||
"title": "联网搜索",
|
||||
"desc": "集成原生网页搜索能力"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountManager": {
|
||||
"addKeySuccess": "API 密钥添加成功",
|
||||
"addAccountSuccess": "账号添加成功",
|
||||
"requiredFields": "需要填写密码以及邮箱或手机号",
|
||||
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
|
||||
"deleteAccountConfirm": "确定要删除此账号吗?",
|
||||
"invalidIdentifier": "账号标识无效,无法执行操作",
|
||||
"testAllConfirm": "测试所有账号的 API 连通性?",
|
||||
"testAllCompleted": "完成:{success}/{total} 可用",
|
||||
"testFailed": "测试失败: {error}",
|
||||
"available": "可用",
|
||||
"inUse": "正在使用",
|
||||
"totalPool": "账号池总数",
|
||||
"accountsUnit": "个账号",
|
||||
"threadsUnit": "线程",
|
||||
"apiKeysTitle": "API 密钥",
|
||||
"apiKeysDesc": "管理 API 访问密钥池",
|
||||
"addKey": "添加密钥",
|
||||
"copied": "已复制",
|
||||
"copyKeyTitle": "复制密钥",
|
||||
"deleteKeyTitle": "删除密钥",
|
||||
"noApiKeys": "未找到 API 密钥",
|
||||
"accountsTitle": "DeepSeek 账号",
|
||||
"accountsDesc": "管理 DeepSeek 账号池",
|
||||
"testAll": "测试全部",
|
||||
"addAccount": "添加账号",
|
||||
"testingAllAccounts": "正在测试所有账号...",
|
||||
"sessionActive": "已建立会话",
|
||||
"reauthRequired": "需重新登录",
|
||||
"testStatusFailed": "上次测试失败",
|
||||
"noAccounts": "未找到任何账号",
|
||||
"modalAddKeyTitle": "添加 API 密钥",
|
||||
"newKeyLabel": "新密钥值",
|
||||
"newKeyPlaceholder": "输入自定义 API 密钥",
|
||||
"generate": "生成",
|
||||
"generateHint": "点击「生成」自动创建随机密钥",
|
||||
"addKeyLoading": "添加中...",
|
||||
"addKeyAction": "添加密钥",
|
||||
"modalAddAccountTitle": "添加 DeepSeek 账号",
|
||||
"emailOptional": "邮箱 (可选)",
|
||||
"mobileOptional": "手机号 (可选)",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "账号密码",
|
||||
"addAccountLoading": "添加中...",
|
||||
"addAccountAction": "添加账号",
|
||||
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号",
|
||||
"searchPlaceholder": "搜索账号...",
|
||||
"searchNoResults": "未找到匹配的账号"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||
"models": {
|
||||
"chat": "非思考模型",
|
||||
"reasoner": "思考模型",
|
||||
"chatSearch": "非思考模型 (带搜索)",
|
||||
"reasonerSearch": "思考模型 (带搜索)"
|
||||
},
|
||||
"missingApiKey": "请提供 API 密钥",
|
||||
"requestFailed": "请求失败",
|
||||
"networkError": "网络错误: {error}",
|
||||
"testSuccess": "{account}: 测试成功 ({time}ms)",
|
||||
"config": "配置",
|
||||
"modelLabel": "模型",
|
||||
"streamMode": "流式模式",
|
||||
"accountSelector": "选择账号",
|
||||
"autoRandom": "🤖 自动 / 随机",
|
||||
"apiKeyOptional": "API 密钥 (可选)",
|
||||
"apiKeyDefault": "默认: ...{suffix}",
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
"statusError": "错误",
|
||||
"reasoningTrace": "思维链过程",
|
||||
"generating": "正在生成响应...",
|
||||
"enterMessage": "输入消息...",
|
||||
"adminConsoleLabel": "DeepSeek 管理员界面"
|
||||
},
|
||||
"batchImport": {
|
||||
"templates": {
|
||||
"full": {
|
||||
"name": "全量配置模板",
|
||||
"desc": "包含密钥、账号及模型映射"
|
||||
},
|
||||
"emailOnly": {
|
||||
"name": "仅邮箱账号",
|
||||
"desc": "批量导入邮箱格式账号"
|
||||
},
|
||||
"mobileOnly": {
|
||||
"name": "仅手机号账号",
|
||||
"desc": "批量导入手机号格式账号"
|
||||
},
|
||||
"keysOnly": {
|
||||
"name": "仅 API 密钥",
|
||||
"desc": "仅添加 API 访问密钥"
|
||||
}
|
||||
},
|
||||
"enterJson": "请输入 JSON 配置内容",
|
||||
"importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号",
|
||||
"templateLoaded": "已加载模板: {name}",
|
||||
"currentConfigLoaded": "当前配置已加载",
|
||||
"fetchConfigFailed": "获取配置失败",
|
||||
"copySuccess": "Base64 配置已复制到剪贴板",
|
||||
"quickTemplates": "快速模板",
|
||||
"dataExport": "数据导出",
|
||||
"dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。",
|
||||
"copyBase64": "复制 Base64 配置",
|
||||
"copied": "已复制",
|
||||
"variableName": "变量名",
|
||||
"jsonEditor": "JSON 编辑器",
|
||||
"loadCurrentConfig": "加载当前配置",
|
||||
"applyConfig": "应用配置",
|
||||
"importing": "正在导入...",
|
||||
"importComplete": "导入操作已完成",
|
||||
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
|
||||
},
|
||||
"settings": {
|
||||
"loadFailed": "加载设置失败",
|
||||
"nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status})",
|
||||
"save": "保存设置",
|
||||
"saving": "保存中...",
|
||||
"saveSuccess": "设置已保存并热更新生效",
|
||||
"saveFailed": "保存设置失败",
|
||||
"securityTitle": "安全设置",
|
||||
"jwtExpireHours": "JWT 有效期(小时)",
|
||||
"newPassword": "面板新密码",
|
||||
"newPasswordPlaceholder": "输入新密码(至少 4 位)",
|
||||
"updatePassword": "修改密码",
|
||||
"updating": "更新中...",
|
||||
"passwordTooShort": "新密码至少 4 位",
|
||||
"passwordUpdated": "密码已更新,需重新登录",
|
||||
"passwordUpdateFailed": "密码更新失败",
|
||||
"runtimeTitle": "并发与队列",
|
||||
"accountMaxInflight": "每账号并发上限",
|
||||
"accountMaxQueue": "账号等待队列上限",
|
||||
"globalMaxInflight": "全局并发上限",
|
||||
"behaviorTitle": "行为设置",
|
||||
"toolcallMode": "Toolcall 模式",
|
||||
"earlyEmitConfidence": "早发置信度",
|
||||
"responsesTTL": "Responses 缓存 TTL(秒)",
|
||||
"embeddingsProvider": "Embeddings Provider",
|
||||
"modelTitle": "模型映射",
|
||||
"claudeMapping": "Claude 映射(JSON)",
|
||||
"modelAliases": "模型别名(JSON)",
|
||||
"backupTitle": "备份与恢复",
|
||||
"loadExport": "加载当前导出",
|
||||
"importModeMerge": "合并导入(默认)",
|
||||
"importModeReplace": "全量覆盖导入",
|
||||
"importNow": "立即导入",
|
||||
"importing": "导入中...",
|
||||
"importPlaceholder": "粘贴要导入的 JSON 配置",
|
||||
"importEmpty": "请先输入导入 JSON",
|
||||
"importInvalidJson": "导入 JSON 格式无效",
|
||||
"importFailed": "导入失败",
|
||||
"importSuccess": "配置导入成功(模式:{mode})",
|
||||
"exportFailed": "导出失败",
|
||||
"exportLoaded": "已加载当前配置导出",
|
||||
"exportJson": "导出 JSON",
|
||||
"invalidJsonField": "{field} 不是有效 JSON 对象",
|
||||
"defaultPasswordWarning": "当前使用默认密码 admin,请尽快在此修改。",
|
||||
"vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。",
|
||||
"autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error})",
|
||||
"retryLoad": "立即重试"
|
||||
},
|
||||
"login": {
|
||||
"welcome": "欢迎回来",
|
||||
"subtitle": "请输入管理员密钥以继续",
|
||||
"adminKeyLabel": "管理员密钥",
|
||||
"adminKeyPlaceholder": "输入您的管理员密钥...",
|
||||
"rememberSession": "记住登录状态",
|
||||
"signIn": "登录",
|
||||
"secureConnection": "安全连接",
|
||||
"adminPortal": "DS2API 管理员门户",
|
||||
"signInFailed": "登录失败",
|
||||
"networkError": "网络错误: {error}"
|
||||
},
|
||||
"vercel": {
|
||||
"tokenRequired": "需要 Vercel 访问令牌",
|
||||
"projectRequired": "需要项目 ID",
|
||||
"syncFailed": "同步失败",
|
||||
"networkError": "网络错误",
|
||||
"title": "Vercel 部署",
|
||||
"description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。",
|
||||
"tokenLabel": "Vercel 访问令牌",
|
||||
"getToken": "获取令牌",
|
||||
"tokenPlaceholderPreconfig": "正在使用预配置的令牌",
|
||||
"tokenPlaceholder": "输入 Vercel 访问令牌",
|
||||
"projectIdLabel": "项目 ID",
|
||||
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
||||
"teamIdLabel": "团队 ID",
|
||||
"optional": "可选",
|
||||
"syncing": "正在同步...",
|
||||
"syncRedeploy": "同步并重新部署",
|
||||
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
||||
"syncSucceeded": "同步成功",
|
||||
"syncFailedLabel": "同步失败",
|
||||
"openDeployment": "访问部署地址",
|
||||
"statusSynced": "已同步",
|
||||
"statusNotSynced": "未同步",
|
||||
"statusNeverSynced": "从未同步",
|
||||
"lastSyncTime": "上次同步: {time}",
|
||||
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
|
||||
"manualRefresh": "手动刷新",
|
||||
"howItWorks": "工作原理",
|
||||
"steps": {
|
||||
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
|
||||
"two": "JSON 被编码为 Base64 以确保格式兼容性。",
|
||||
"three": "更新 Vercel 项目中的环境变量:",
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
zeabur.yaml
Normal file
60
zeabur.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
# yaml-language-server: $schema=https://schema.zeabur.app/template.json
|
||||
apiVersion: zeabur.com/v1
|
||||
kind: Template
|
||||
metadata:
|
||||
name: DS2API
|
||||
spec:
|
||||
description: DeepSeek Web 对话转 OpenAI/Claude/Gemini 兼容 API(Go 实现,含 WebUI)
|
||||
tags:
|
||||
- DeepSeek
|
||||
- API
|
||||
- Go
|
||||
readme: |-
|
||||
# DS2API (Zeabur)
|
||||
|
||||
## After deployment
|
||||
- Admin panel: `/admin`
|
||||
- Health check: `/healthz`
|
||||
- Config is persisted at `/data/config.json` (mounted volume)
|
||||
|
||||
## First-time setup
|
||||
1. Open your service URL, then visit `/admin`
|
||||
2. Login with `DS2API_ADMIN_KEY` (shown in Zeabur env/instructions)
|
||||
3. Import / edit config in Admin UI (saved to `/data/config.json`)
|
||||
|
||||
services:
|
||||
- name: ds2api
|
||||
template: GIT
|
||||
spec:
|
||||
source:
|
||||
source: GITHUB
|
||||
repo: 1139136822
|
||||
branch: main
|
||||
rootDirectory: /
|
||||
ports:
|
||||
- id: web
|
||||
port: 5001
|
||||
type: HTTP
|
||||
volumes:
|
||||
- id: data
|
||||
dir: /data
|
||||
env:
|
||||
PORT:
|
||||
default: "5001"
|
||||
LOG_LEVEL:
|
||||
default: "INFO"
|
||||
DS2API_ADMIN_KEY:
|
||||
default: ${PASSWORD}
|
||||
expose: true
|
||||
DS2API_CONFIG_PATH:
|
||||
default: /data/config.json
|
||||
instructions:
|
||||
- title: Admin panel
|
||||
content: Visit `/admin` on your service URL.
|
||||
- title: DS2API admin key
|
||||
content: ${DS2API_ADMIN_KEY}
|
||||
healthCheck:
|
||||
type: HTTP
|
||||
port: web
|
||||
http:
|
||||
path: /healthz
|
||||
Reference in New Issue
Block a user