Compare commits
32 Commits
main
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dac55f0fde | ||
|
|
938dc9522b | ||
|
|
5114ad0677 | ||
|
|
d46df94f05 | ||
|
|
aa730395f1 | ||
|
|
d2b30dfc95 | ||
|
|
987b7ecd22 | ||
|
|
5f86839c7e | ||
|
|
8f3c41ae77 | ||
|
|
8bff691089 | ||
|
|
22fd1741ab | ||
|
|
9b0ec8ed48 | ||
|
|
95648353e4 | ||
|
|
2f8637048e | ||
|
|
b2232f4355 | ||
|
|
b44faec66b | ||
|
|
3b592895c6 | ||
|
|
e0b6eb3a59 | ||
|
|
6f57dcd2f5 | ||
|
|
8ca103342d | ||
|
|
22ae14f0d7 | ||
|
|
f982544825 | ||
|
|
438410708f | ||
|
|
75af3db11f | ||
|
|
db48108d21 | ||
|
|
22ef5b2f80 | ||
|
|
28f7e9eb2e | ||
|
|
fc377dae3e | ||
|
|
df14a0bf18 | ||
|
|
c609cb13b2 | ||
|
|
a42b397607 | ||
|
|
9f8a4ec050 |
83
.agents/skills/classic-to-default-sync/SKILL.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: classic-to-default-sync
|
||||||
|
description: Inspect a given commit's web/classic changes and sync all features/fixes to web/default. Use when the user provides a commit ID and wants to audit whether web/default already has the same features as web/classic, port missing features, improve suboptimal implementations, fix bugs, and remove redundant code. Trigger phrases include: "/classic-to-default-sync <hash>", "classic-to-default-sync <hash>", "sync classic to default", "port from classic", "compare classic commit", "classic 和 default 对比", "把这次 classic 的修改同步到 default", "查看这次提交 classic 中的修改并同步", or any request supplying a commit hash together with classic/default comparison intent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Classic-to-Default Sync
|
||||||
|
|
||||||
|
Given a **commit ID**, audit all `web/classic` changes and ensure `web/default` reaches feature parity with the best possible implementation.
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
The user must supply a `<commit-id>`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1 — Extract classic diff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git show <commit-id> -- web/classic
|
||||||
|
```
|
||||||
|
|
||||||
|
Read every changed file in `web/classic`. Identify the **logical changes** (new features, UI/UX improvements, bug fixes, config tweaks, removed dead code, etc.) — not just line diffs.
|
||||||
|
|
||||||
|
### Step 2 — Map to default counterparts
|
||||||
|
|
||||||
|
For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that:
|
||||||
|
|
||||||
|
- `web/classic` uses **React 18 + Vite + Semi Design**
|
||||||
|
- `web/default` uses **React 19 + Rsbuild + Radix UI + Tailwind CSS**
|
||||||
|
- Component names, file paths, and API shapes may differ; match by **functionality**, not filename.
|
||||||
|
|
||||||
|
### Step 3 — Triage each change
|
||||||
|
|
||||||
|
Classify every logical change as one of:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| ✅ Already present & optimal | No action needed |
|
||||||
|
| ⚠️ Present but suboptimal | Improve: logic, layout, style, or code quality |
|
||||||
|
| ❌ Missing | Implement from scratch in default's stack |
|
||||||
|
|
||||||
|
### Step 4 — Implement
|
||||||
|
|
||||||
|
For each **⚠️** or **❌** item:
|
||||||
|
|
||||||
|
1. **Read the target file(s) in `web/default`** before editing (required by project conventions).
|
||||||
|
2. Implement using `web/default` conventions:
|
||||||
|
- React 19 patterns (hooks, Suspense, etc.)
|
||||||
|
- Radix UI primitives where applicable
|
||||||
|
- Tailwind CSS for styling (no inline styles or Semi Design imports)
|
||||||
|
- `useTranslation()` + `t('English key')` for all user-visible strings
|
||||||
|
- TypeScript — explicit types, no `any`
|
||||||
|
- No dead code, no redundant comments
|
||||||
|
3. Follow **Rule 6** (pointer types for optional relay DTOs) if touching relay-related TS types.
|
||||||
|
4. After editing, run `ReadLints` on changed files and fix any introduced lint errors.
|
||||||
|
|
||||||
|
### Step 5 — i18n
|
||||||
|
|
||||||
|
If any new user-visible strings were added, run the i18n sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/default && bun run i18n:sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add missing translations for all supported locales (en, zh, fr, ja, ru, vi) following the **i18n-translate** skill.
|
||||||
|
|
||||||
|
### Step 6 — Report
|
||||||
|
|
||||||
|
Summarise the work in a concise table:
|
||||||
|
|
||||||
|
| # | Change (from classic commit) | Status | Action taken |
|
||||||
|
|---|------------------------------|--------|--------------|
|
||||||
|
| 1 | … | ✅ / ⚠️ / ❌ | None / Improved / Implemented |
|
||||||
|
|
||||||
|
If every item is ✅ with no action needed, simply reply: **"已完成 — web/default 已具备此次提交的所有功能,且实现质量良好,无需修改。"**
|
||||||
|
|
||||||
|
## Quality bar
|
||||||
|
|
||||||
|
- No unused imports, variables, or components
|
||||||
|
- No commented-out code left behind
|
||||||
|
- Consistent naming with surrounding `web/default` code
|
||||||
|
- All interactive elements accessible (keyboard nav, ARIA labels where Radix doesn't provide them automatically)
|
||||||
|
- No regressions: existing behaviour in `web/default` must not break
|
||||||
254
.agents/skills/i18n-translate/SKILL.md
Executable file
@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
name: i18n-translate
|
||||||
|
description: >-
|
||||||
|
Complete and maintain frontend i18n translations for this project. Covers
|
||||||
|
finding missing translation keys, detecting untranslated entries, and adding
|
||||||
|
translations for all supported locales (en, zh, fr, ja, ru, vi). Use when the
|
||||||
|
user asks to add translations, fix i18n, complete missing translations, or
|
||||||
|
when new UI text needs to be internationalized.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend i18n Translation Workflow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json`
|
||||||
|
- Format: flat JSON under `"translation"` key, keys are English source strings
|
||||||
|
- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)
|
||||||
|
- Sync script: `bun run i18n:sync` (from `web/default/`)
|
||||||
|
- All `t()` calls must have corresponding keys in every locale file
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Run sync and read report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/default && bun run i18n:sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).
|
||||||
|
|
||||||
|
### Step 2: Find missing keys (used in code but not in locale files)
|
||||||
|
|
||||||
|
Create and run `web/default/scripts/find-missing-keys.mjs`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||||
|
const SRC_DIR = path.resolve('src')
|
||||||
|
|
||||||
|
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
|
||||||
|
const enKeys = new Set(Object.keys(en.translation))
|
||||||
|
|
||||||
|
const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g
|
||||||
|
const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g
|
||||||
|
|
||||||
|
async function walkDir(dir) {
|
||||||
|
const files = []
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue
|
||||||
|
files.push(...(await walkDir(fullPath)))
|
||||||
|
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await walkDir(SRC_DIR)
|
||||||
|
const missingKeys = new Map()
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = await fs.readFile(file, 'utf8')
|
||||||
|
const relPath = path.relative(SRC_DIR, file)
|
||||||
|
for (const regex of [tCallRegex, tCallMultilineRegex]) {
|
||||||
|
regex.lastIndex = 0
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
const key = match[1]
|
||||||
|
if (key.startsWith('{{') || key.includes('${')) continue
|
||||||
|
if (!enKeys.has(key)) {
|
||||||
|
if (!missingKeys.has(key)) missingKeys.set(key, [])
|
||||||
|
missingKeys.get(key).push(relPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingKeys.size === 0) {
|
||||||
|
console.log('All t() keys found in en.json!')
|
||||||
|
} else {
|
||||||
|
console.log(`Found ${missingKeys.size} missing keys:\n`)
|
||||||
|
for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
console.log(` "${key}"`)
|
||||||
|
for (const f of [...new Set(files)]) console.log(` -> ${f}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Find untranslated entries (value equals English)
|
||||||
|
|
||||||
|
Create and run `web/default/scripts/find-untranslated.mjs`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||||
|
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
|
||||||
|
const enTrans = en.translation
|
||||||
|
|
||||||
|
// Brand names, URLs, technical terms — skip these
|
||||||
|
const skipPatterns = [
|
||||||
|
/^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,
|
||||||
|
/^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,
|
||||||
|
/^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,
|
||||||
|
/^"default/, /^\/status\//, /^\/your\//, /^example\.com/,
|
||||||
|
/^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,
|
||||||
|
/^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,
|
||||||
|
/^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const brandNames = new Set([
|
||||||
|
'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',
|
||||||
|
'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',
|
||||||
|
'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',
|
||||||
|
'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',
|
||||||
|
'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',
|
||||||
|
'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',
|
||||||
|
'WeChat','Xinference','Xunfei','AI Proxy','One API',
|
||||||
|
])
|
||||||
|
|
||||||
|
const locales = ['fr', 'ja', 'ru', 'zh', 'vi']
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))
|
||||||
|
const locTrans = locFile.translation
|
||||||
|
const untranslated = {}
|
||||||
|
|
||||||
|
for (const [key, enVal] of Object.entries(enTrans)) {
|
||||||
|
const locVal = locTrans[key]
|
||||||
|
if (locVal === undefined || locVal !== enVal) continue
|
||||||
|
if (brandNames.has(key)) continue
|
||||||
|
if (skipPatterns.some(p => p.test(key))) continue
|
||||||
|
if (typeof enVal === 'string' && enVal.length < 4) continue
|
||||||
|
if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Object.keys(untranslated).length
|
||||||
|
if (count > 0) {
|
||||||
|
console.log(`\n=== ${locale} (${count} untranslated) ===`)
|
||||||
|
for (const [k, v] of Object.entries(untranslated))
|
||||||
|
console.log(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||||
|
} else {
|
||||||
|
console.log(`\n=== ${locale}: all translated ===`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add translations
|
||||||
|
|
||||||
|
Create `web/default/scripts/add-missing-keys.mjs` with this structure:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||||
|
|
||||||
|
function stableStringify(obj) {
|
||||||
|
return JSON.stringify(obj, null, 2) + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKeys = {
|
||||||
|
en: { /* "key": "English value" */ },
|
||||||
|
zh: { /* "key": "中文翻译" */ },
|
||||||
|
fr: { /* "key": "Traduction française" */ },
|
||||||
|
ja: { /* "key": "日本語翻訳" */ },
|
||||||
|
ru: { /* "key": "Русский перевод" */ },
|
||||||
|
vi: { /* "key": "Bản dịch tiếng Việt" */ },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let totalAdded = 0
|
||||||
|
|
||||||
|
for (const [locale, trans] of Object.entries(newKeys)) {
|
||||||
|
const filePath = path.join(LOCALES_DIR, `${locale}.json`)
|
||||||
|
const json = JSON.parse(await fs.readFile(filePath, 'utf8'))
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const [key, value] of Object.entries(trans)) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {
|
||||||
|
json.translation[key] = value
|
||||||
|
count++
|
||||||
|
} else if (json.translation[key] !== value) {
|
||||||
|
json.translation[key] = value
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
json.translation = Object.fromEntries(
|
||||||
|
Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
)
|
||||||
|
await fs.writeFile(filePath, stableStringify(json), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${locale}: ${count} translations applied`)
|
||||||
|
totalAdded += count
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nTotal: ${totalAdded} translations applied`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => { console.error(err); process.exitCode = 1 })
|
||||||
|
```
|
||||||
|
|
||||||
|
Populate the `newKeys` object with actual translations for each locale.
|
||||||
|
|
||||||
|
### Step 5: Verify and clean up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/default
|
||||||
|
node scripts/add-missing-keys.mjs # apply translations
|
||||||
|
node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"
|
||||||
|
bun run i18n:sync # normalize file order
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete temporary scripts after completion.
|
||||||
|
|
||||||
|
## Translation Guidelines
|
||||||
|
|
||||||
|
| Language | Code | Notes |
|
||||||
|
|----------|------|-------|
|
||||||
|
| English | en | Base locale, key = value |
|
||||||
|
| Chinese | zh | Fallback locale, must be complete |
|
||||||
|
| French | fr | Many English cognates are valid (e.g., "Configuration") |
|
||||||
|
| Japanese | ja | Use katakana for technical loanwords |
|
||||||
|
| Russian | ru | Use formal register |
|
||||||
|
| Vietnamese | vi | Use standard Vietnamese |
|
||||||
|
|
||||||
|
**Keep as English (do not translate):**
|
||||||
|
- Brand/product names (OpenAI, Claude, Gemini, etc.)
|
||||||
|
- URLs and email placeholders
|
||||||
|
- Technical identifiers (JSON keys, API paths, model names)
|
||||||
|
- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)
|
||||||
|
|
||||||
|
**Always translate:**
|
||||||
|
- UI labels, button text, error messages, descriptions
|
||||||
|
- Time units (hours, minutes, months, years)
|
||||||
|
- Action words (Move, Show, Delete, etc.)
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
1. All scripts run from `web/default/` directory
|
||||||
|
2. Use `node scripts/xxx.mjs` (ESM format with top-level await)
|
||||||
|
3. Sort keys alphabetically when writing locale files
|
||||||
|
4. Always run `bun run i18n:sync` as the final step
|
||||||
|
5. Delete temporary scripts after completion
|
||||||
|
6. The `{{variable}}` placeholders in keys must be preserved in all translations
|
||||||
2934
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
@ -1,4 +1,4 @@
|
|||||||
name: Publish Docker image (Multi Registries, native amd64+arm64)
|
name: Publish Docker image (Multi-arch)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -14,7 +14,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_single_arch:
|
build_single_arch:
|
||||||
name: Build & push (${{ matrix.arch }}) [native]
|
name: Build & push (${{ matrix.arch }})
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@ -26,6 +26,8 @@ jobs:
|
|||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
@ -34,58 +36,46 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
||||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
- name: Resolve tag & write VERSION
|
- name: Resolve tag & write VERSION
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
TAG="${{ github.event.inputs.tag }}"
|
||||||
# Verify tag exists
|
|
||||||
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
|
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||||
echo "Error: Tag '$TAG' does not exist in the repository"
|
echo "::error::Tag '$TAG' does not exist"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TAG=${GITHUB_REF#refs/tags/}
|
TAG=${GITHUB_REF#refs/tags/}
|
||||||
fi
|
fi
|
||||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||||
echo "$TAG" > VERSION
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
echo "Building tag: $TAG for ${{ matrix.arch }}"
|
echo "${TAG}" > VERSION
|
||||||
|
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
|
||||||
|
|
||||||
# - name: Normalize GHCR repository
|
|
||||||
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata (labels)
|
- name: Extract metadata (labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: calciumion/new-api
|
||||||
calciumion/new-api
|
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}
|
|
||||||
|
|
||||||
- name: Build & push single-arch (to both registries)
|
- name: Build & push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@ -93,8 +83,6 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
|
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
|
||||||
calciumion/new-api:latest-${{ matrix.arch }}
|
calciumion/new-api:latest-${{ matrix.arch }}
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
|
|
||||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
@ -102,81 +90,52 @@ jobs:
|
|||||||
sbom: true
|
sbom: true
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
uses: sigstore/cosign-installer@v3
|
||||||
|
|
||||||
- name: Sign image with cosign
|
- name: Sign image with cosign
|
||||||
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
||||||
|
|
||||||
- name: Output digest
|
- name: Image summary
|
||||||
run: |
|
run: |
|
||||||
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
|
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
|
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
create_manifests:
|
create_manifests:
|
||||||
name: Create multi-arch manifests (Docker Hub)
|
name: Create multi-arch manifests
|
||||||
needs: [build_single_arch]
|
needs: [build_single_arch]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Extract tag
|
- name: Set version
|
||||||
run: |
|
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
|
||||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
|
||||||
echo "TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
#
|
|
||||||
# - name: Normalize GHCR repository
|
|
||||||
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create & push manifest (Docker Hub - version)
|
- name: Create & push manifest (version)
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
-t calciumion/new-api:${TAG} \
|
-t calciumion/new-api:${TAG} \
|
||||||
calciumion/new-api:${TAG}-amd64 \
|
calciumion/new-api:${TAG}-amd64 \
|
||||||
calciumion/new-api:${TAG}-arm64
|
calciumion/new-api:${TAG}-arm64
|
||||||
|
|
||||||
- name: Create & push manifest (Docker Hub - latest)
|
- name: Create & push manifest (latest)
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
-t calciumion/new-api:latest \
|
-t calciumion/new-api:latest \
|
||||||
calciumion/new-api:latest-amd64 \
|
calciumion/new-api:latest-amd64 \
|
||||||
calciumion/new-api:latest-arm64
|
calciumion/new-api:latest-arm64
|
||||||
|
|
||||||
- name: Output manifest digest
|
- name: Manifest summary
|
||||||
run: |
|
run: |
|
||||||
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
|
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
|
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
|
||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ---- GHCR ----
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# - name: Create & push manifest (GHCR - version)
|
|
||||||
# run: |
|
|
||||||
# docker buildx imagetools create \
|
|
||||||
# -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64
|
|
||||||
#
|
|
||||||
# - name: Create & push manifest (GHCR - latest)
|
|
||||||
# run: |
|
|
||||||
# docker buildx imagetools create \
|
|
||||||
# -t ghcr.io/${GHCR_REPOSITORY}:latest \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \
|
|
||||||
# ghcr.io/${GHCR_REPOSITORY}:latest-arm64
|
|
||||||
42
.github/workflows/release.yml
vendored
@ -29,14 +29,22 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ..
|
cd ../..
|
||||||
|
- name: Build Frontend (classic)
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
run: |
|
||||||
|
cd web/classic
|
||||||
|
bun install
|
||||||
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
@ -78,15 +86,23 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ..
|
cd ../..
|
||||||
|
- name: Build Frontend (classic)
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
run: |
|
||||||
|
cd web/classic
|
||||||
|
bun install
|
||||||
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
@ -126,14 +142,22 @@ jobs:
|
|||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend
|
- name: Build Frontend (default)
|
||||||
env:
|
env:
|
||||||
CI: ""
|
CI: ""
|
||||||
run: |
|
run: |
|
||||||
cd web
|
cd web/default
|
||||||
bun install
|
bun install
|
||||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ..
|
cd ../..
|
||||||
|
- name: Build Frontend (classic)
|
||||||
|
env:
|
||||||
|
CI: ""
|
||||||
|
run: |
|
||||||
|
cd web/classic
|
||||||
|
bun install
|
||||||
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
5
.gitignore
vendored
@ -8,6 +8,9 @@ upload
|
|||||||
build
|
build
|
||||||
*.db-journal
|
*.db-journal
|
||||||
logs
|
logs
|
||||||
|
web/default/dist
|
||||||
|
web/classic/dist
|
||||||
|
web/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
.env
|
.env
|
||||||
one-api
|
one-api
|
||||||
@ -19,9 +22,9 @@ tiktoken_cache
|
|||||||
.gocache
|
.gocache
|
||||||
.gomodcache/
|
.gomodcache/
|
||||||
.cache
|
.cache
|
||||||
web/bun.lock
|
|
||||||
plans
|
plans
|
||||||
.claude
|
.claude
|
||||||
|
.cursor
|
||||||
|
|
||||||
electron/node_modules
|
electron/node_modules
|
||||||
electron/dist
|
electron/dist
|
||||||
|
|||||||
21
AGENTS.md
@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
||||||
- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
|
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
|
||||||
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
||||||
- **Cache**: Redis (go-redis) + in-memory cache
|
- **Cache**: Redis (go-redis) + in-memory cache
|
||||||
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
||||||
@ -33,8 +33,10 @@ types/ — Type definitions (relay formats, file sources, errors)
|
|||||||
i18n/ — Backend internationalization (go-i18n, en/zh)
|
i18n/ — Backend internationalization (go-i18n, en/zh)
|
||||||
oauth/ — OAuth provider implementations
|
oauth/ — OAuth provider implementations
|
||||||
pkg/ — Internal packages (cachex, ionet)
|
pkg/ — Internal packages (cachex, ionet)
|
||||||
web/ — React frontend
|
web/ — Frontend themes container
|
||||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
web/default/ — Default frontend (React 19, Rsbuild, Radix UI, Tailwind)
|
||||||
|
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
|
||||||
|
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
@ -43,13 +45,12 @@ web/ — React frontend
|
|||||||
- Library: `nicksnyder/go-i18n/v2`
|
- Library: `nicksnyder/go-i18n/v2`
|
||||||
- Languages: en, zh
|
- Languages: en, zh
|
||||||
|
|
||||||
### Frontend (`web/src/i18n/`)
|
### Frontend (`web/default/src/i18n/`)
|
||||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||||
- Languages: zh (fallback), en, fr, ru, ja, vi
|
- Languages: en (base), zh (fallback), fr, ru, ja, vi
|
||||||
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
|
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
|
||||||
- Usage: `useTranslation()` hook, call `t('中文key')` in components
|
- Usage: `useTranslation()` hook, call `t('English key')` in components
|
||||||
- Semi UI locale synced via `SemiLocaleWrapper`
|
- CLI tools: `bun run i18n:sync` (from `web/default/`)
|
||||||
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
|
|||||||
|
|
||||||
### Rule 3: Frontend — Prefer Bun
|
### Rule 3: Frontend — Prefer Bun
|
||||||
|
|
||||||
Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
|
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
|
||||||
- `bun install` for dependency installation
|
- `bun install` for dependency installation
|
||||||
- `bun run dev` for development server
|
- `bun run dev` for development server
|
||||||
- `bun run build` for production build
|
- `bun run build` for production build
|
||||||
|
|||||||
21
CLAUDE.md
@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
|
||||||
- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
|
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
|
||||||
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
|
||||||
- **Cache**: Redis (go-redis) + in-memory cache
|
- **Cache**: Redis (go-redis) + in-memory cache
|
||||||
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
|
||||||
@ -33,8 +33,10 @@ types/ — Type definitions (relay formats, file sources, errors)
|
|||||||
i18n/ — Backend internationalization (go-i18n, en/zh)
|
i18n/ — Backend internationalization (go-i18n, en/zh)
|
||||||
oauth/ — OAuth provider implementations
|
oauth/ — OAuth provider implementations
|
||||||
pkg/ — Internal packages (cachex, ionet)
|
pkg/ — Internal packages (cachex, ionet)
|
||||||
web/ — React frontend
|
web/ — Frontend themes container
|
||||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
web/default/ — Default frontend (React 19, Rsbuild, Radix UI, Tailwind)
|
||||||
|
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
|
||||||
|
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Internationalization (i18n)
|
## Internationalization (i18n)
|
||||||
@ -43,13 +45,12 @@ web/ — React frontend
|
|||||||
- Library: `nicksnyder/go-i18n/v2`
|
- Library: `nicksnyder/go-i18n/v2`
|
||||||
- Languages: en, zh
|
- Languages: en, zh
|
||||||
|
|
||||||
### Frontend (`web/src/i18n/`)
|
### Frontend (`web/default/src/i18n/`)
|
||||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||||
- Languages: zh (fallback), en, fr, ru, ja, vi
|
- Languages: en (base), zh (fallback), fr, ru, ja, vi
|
||||||
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
|
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
|
||||||
- Usage: `useTranslation()` hook, call `t('中文key')` in components
|
- Usage: `useTranslation()` hook, call `t('English key')` in components
|
||||||
- Semi UI locale synced via `SemiLocaleWrapper`
|
- CLI tools: `bun run i18n:sync` (from `web/default/`)
|
||||||
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@ -93,7 +94,7 @@ All database code MUST be fully compatible with all three databases simultaneous
|
|||||||
|
|
||||||
### Rule 3: Frontend — Prefer Bun
|
### Rule 3: Frontend — Prefer Bun
|
||||||
|
|
||||||
Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
|
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
|
||||||
- `bun install` for dependency installation
|
- `bun install` for dependency installation
|
||||||
- `bun run dev` for development server
|
- `bun run dev` for development server
|
||||||
- `bun run build` for production build
|
- `bun run build` for production build
|
||||||
|
|||||||
19
Dockerfile
@ -1,13 +1,23 @@
|
|||||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY web/package.json .
|
COPY web/default/package.json .
|
||||||
COPY web/bun.lock .
|
COPY web/default/bun.lock .
|
||||||
RUN bun install
|
RUN bun install
|
||||||
COPY ./web .
|
COPY ./web/default .
|
||||||
COPY ./VERSION .
|
COPY ./VERSION .
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||||
|
|
||||||
|
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY web/classic/package.json .
|
||||||
|
COPY web/classic/bun.lock .
|
||||||
|
RUN bun install
|
||||||
|
COPY ./web/classic .
|
||||||
|
COPY ./VERSION .
|
||||||
|
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||||
|
|
||||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||||
ENV GO111MODULE=on CGO_ENABLED=0
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
|
|
||||||
@ -22,7 +32,8 @@ ADD go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /build/dist ./web/dist
|
COPY --from=builder /build/dist ./web/default/dist
|
||||||
|
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||||
|
|||||||
35
Dockerfile.dev
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Backend-only build for frontend development
|
||||||
|
# Skips frontend build, uses a placeholder for //go:embed web/dist
|
||||||
|
|
||||||
|
FROM golang:1.26.1-alpine AS builder
|
||||||
|
|
||||||
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
|
||||||
|
ENV GOEXPERIMENT=greenteagc
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
ADD go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p web/default/dist web/classic/dist && \
|
||||||
|
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/default/dist/index.html && \
|
||||||
|
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/classic/dist/index.html
|
||||||
|
|
||||||
|
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& update-ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /build/new-api /
|
||||||
|
EXPOSE 3000
|
||||||
|
WORKDIR /data
|
||||||
|
ENTRYPOINT ["/new-api"]
|
||||||
459
README.en.md
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# New API
|
||||||
|
|
||||||
|
🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.md">中文</a> |
|
||||||
|
<strong>English</strong> |
|
||||||
|
<a href="./README.fr.md">Français</a> |
|
||||||
|
<a href="./README.ja.md">日本語</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
||||||
|
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/8227" target="_blank">
|
||||||
|
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#-quick-start">Quick Start</a> •
|
||||||
|
<a href="#-key-features">Key Features</a> •
|
||||||
|
<a href="#-deployment">Deployment</a> •
|
||||||
|
<a href="#-documentation">Documentation</a> •
|
||||||
|
<a href="#-help-support">Help</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 Project Description
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||||
|
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||||
|
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Trusted Partners
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>No particular order</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target="_blank">
|
||||||
|
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
|
||||||
|
</a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target="_blank">
|
||||||
|
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
|
||||||
|
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.aliyun.com/" target="_blank">
|
||||||
|
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
|
||||||
|
</a>
|
||||||
|
<a href="https://io.net/" target="_blank">
|
||||||
|
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Special Thanks
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
|
||||||
|
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Thanks to <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> for providing free open-source development license for this project</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the project
|
||||||
|
git clone https://github.com/QuantumNous/new-api.git
|
||||||
|
cd new-api
|
||||||
|
|
||||||
|
# Edit docker-compose.yml configuration
|
||||||
|
nano docker-compose.yml
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Using Docker Commands</strong></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the latest image
|
||||||
|
docker pull calciumion/new-api:latest
|
||||||
|
|
||||||
|
# Using SQLite (default)
|
||||||
|
docker run --name new-api -d --restart always \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v ./data:/data \
|
||||||
|
calciumion/new-api:latest
|
||||||
|
|
||||||
|
# Using MySQL
|
||||||
|
docker run --name new-api -d --restart always \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v ./data:/data \
|
||||||
|
calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||||
|
|
||||||
|
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [](https://deepwiki.com/QuantumNous/new-api)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**Quick Navigation:**
|
||||||
|
|
||||||
|
| Category | Link |
|
||||||
|
|------|------|
|
||||||
|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
|
||||||
|
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
|
||||||
|
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
|
||||||
|
| ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||||
|
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
|
||||||
|
|
||||||
|
### 🎨 Core Functions
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|------|------|
|
||||||
|
| 🎨 New UI | Modern user interface design |
|
||||||
|
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
|
||||||
|
| 🔄 Data Compatibility | Fully compatible with the original One API database |
|
||||||
|
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||||
|
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||||
|
|
||||||
|
### 💰 Payment and Billing
|
||||||
|
|
||||||
|
- ✅ Online recharge (EPay, Stripe)
|
||||||
|
- ✅ Pay-per-use model pricing
|
||||||
|
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||||
|
- ✅ Flexible billing policy configuration
|
||||||
|
|
||||||
|
### 🔐 Authorization and Security
|
||||||
|
|
||||||
|
- 😈 Discord authorization login
|
||||||
|
- 🤖 LinuxDO authorization login
|
||||||
|
- 📱 Telegram authorization login
|
||||||
|
- 🔑 OIDC unified authentication
|
||||||
|
|
||||||
|
### 🚀 Advanced Features
|
||||||
|
|
||||||
|
**API Format Support:**
|
||||||
|
- ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||||
|
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
|
||||||
|
- ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||||
|
- ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||||
|
- 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
|
||||||
|
|
||||||
|
**Intelligent Routing:**
|
||||||
|
- ⚖️ Channel weighted random
|
||||||
|
- 🔄 Automatic retry on failure
|
||||||
|
- 🚦 User-level model rate limiting
|
||||||
|
|
||||||
|
**Format Conversion:**
|
||||||
|
- 🔄 **OpenAI Compatible ⇄ Claude Messages**
|
||||||
|
- 🔄 **OpenAI Compatible → Google Gemini**
|
||||||
|
- 🔄 **Google Gemini → OpenAI Compatible** - Text only, function calling not supported yet
|
||||||
|
- 🚧 **OpenAI Compatible ⇄ OpenAI Responses** - In development
|
||||||
|
- 🔄 **Thinking-to-content functionality**
|
||||||
|
|
||||||
|
**Reasoning Effort Support:**
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>View detailed configuration</summary>
|
||||||
|
|
||||||
|
**OpenAI series models:**
|
||||||
|
- `o3-mini-high` - High reasoning effort
|
||||||
|
- `o3-mini-medium` - Medium reasoning effort
|
||||||
|
- `o3-mini-low` - Low reasoning effort
|
||||||
|
- `gpt-5-high` - High reasoning effort
|
||||||
|
- `gpt-5-medium` - Medium reasoning effort
|
||||||
|
- `gpt-5-low` - Low reasoning effort
|
||||||
|
|
||||||
|
**Claude thinking models:**
|
||||||
|
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
|
||||||
|
|
||||||
|
**Google Gemini series models:**
|
||||||
|
- `gemini-2.5-flash-thinking` - Enable thinking mode
|
||||||
|
- `gemini-2.5-flash-nothinking` - Disable thinking mode
|
||||||
|
- `gemini-2.5-pro-thinking` - Enable thinking mode
|
||||||
|
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
|
||||||
|
- You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Model Support
|
||||||
|
|
||||||
|
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
|
||||||
|
|
||||||
|
| Model Type | Description | Documentation |
|
||||||
|
|---------|------|------|
|
||||||
|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
|
||||||
|
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
|
||||||
|
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
|
||||||
|
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
|
||||||
|
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
|
||||||
|
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
|
||||||
|
| 🔧 Dify | ChatFlow mode | - |
|
||||||
|
| 🎯 Custom | Supports complete call address | - |
|
||||||
|
|
||||||
|
### 📡 Supported Interfaces
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>View complete interface list</summary>
|
||||||
|
|
||||||
|
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
|
||||||
|
- [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
|
||||||
|
- [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
|
||||||
|
- [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
|
||||||
|
- [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
|
||||||
|
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
|
||||||
|
- [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
|
||||||
|
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
|
||||||
|
- [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
|
||||||
|
- [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Latest Docker image:** `calciumion/new-api:latest`
|
||||||
|
|
||||||
|
### 📋 Deployment Requirements
|
||||||
|
|
||||||
|
| Component | Requirement |
|
||||||
|
|------|------|
|
||||||
|
| **Local database** | SQLite (Docker must mount `/data` directory)|
|
||||||
|
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
|
||||||
|
| **Container engine** | Docker / Docker Compose |
|
||||||
|
|
||||||
|
### ⚙️ Environment Variable Configuration
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Common environment variable configuration</summary>
|
||||||
|
|
||||||
|
| Variable Name | Description | Default Value |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
|
||||||
|
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
|
||||||
|
| `SQL_DSN` | Database connection string | - |
|
||||||
|
| `REDIS_CONN_STRING` | Redis connection string | - |
|
||||||
|
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
|
||||||
|
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
|
||||||
|
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
|
||||||
|
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
|
||||||
|
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
|
||||||
|
| `PYROSCOPE_URL` | Pyroscope server address | - |
|
||||||
|
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
|
||||||
|
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
|
||||||
|
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
|
||||||
|
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
|
||||||
|
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
|
||||||
|
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
|
||||||
|
|
||||||
|
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 🔧 Deployment Methods
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the project
|
||||||
|
git clone https://github.com/QuantumNous/new-api.git
|
||||||
|
cd new-api
|
||||||
|
|
||||||
|
# Edit configuration
|
||||||
|
nano docker-compose.yml
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Method 2: Docker Commands</strong></summary>
|
||||||
|
|
||||||
|
**Using SQLite:**
|
||||||
|
```bash
|
||||||
|
docker run --name new-api -d --restart always \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v ./data:/data \
|
||||||
|
calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using MySQL:**
|
||||||
|
```bash
|
||||||
|
docker run --name new-api -d --restart always \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-v ./data:/data \
|
||||||
|
calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
> **💡 Path explanation:**
|
||||||
|
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
|
||||||
|
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Method 3: BaoTa Panel</strong></summary>
|
||||||
|
|
||||||
|
1. Install BaoTa Panel (≥ 9.2.0 version)
|
||||||
|
2. Search for **New-API** in the application store
|
||||||
|
3. One-click installation
|
||||||
|
|
||||||
|
📖 [Tutorial with images](./docs/BT.md)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### ⚠️ Multi-machine Deployment Considerations
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
|
||||||
|
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
|
||||||
|
|
||||||
|
### 🔄 Channel Retry and Cache
|
||||||
|
|
||||||
|
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
|
||||||
|
|
||||||
|
**Cache configuration:**
|
||||||
|
- `REDIS_CONN_STRING`: Redis cache (recommended)
|
||||||
|
- `MEMORY_CACHE_ENABLED`: Memory cache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Projects
|
||||||
|
|
||||||
|
### Upstream Projects
|
||||||
|
|
||||||
|
| Project | Description |
|
||||||
|
|------|------|
|
||||||
|
| [One API](https://github.com/songquanpeng/one-api) | Original project base |
|
||||||
|
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
|
||||||
|
|
||||||
|
### Supporting Tools
|
||||||
|
|
||||||
|
| Project | Description |
|
||||||
|
|------|------|
|
||||||
|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||||
|
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Help Support
|
||||||
|
|
||||||
|
### 📖 Documentation Resources
|
||||||
|
|
||||||
|
| Resource | Link |
|
||||||
|
|------|------|
|
||||||
|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
|
||||||
|
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
|
||||||
|
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
|
||||||
|
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
|
||||||
|
|
||||||
|
### 🤝 Contribution Guide
|
||||||
|
|
||||||
|
Welcome all forms of contribution!
|
||||||
|
|
||||||
|
- 🐛 Report Bugs
|
||||||
|
- 💡 Propose New Features
|
||||||
|
- 📝 Improve Documentation
|
||||||
|
- 🔧 Submit Code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Star History
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
### 💖 Thank you for using New API
|
||||||
|
|
||||||
|
If this project is helpful to you, welcome to give us a ⭐️ Star!
|
||||||
|
|
||||||
|
**[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
|
||||||
|
|
||||||
|
<sub>Built with ❤️ by QuantumNous</sub>
|
||||||
|
|
||||||
|
</div>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# New API
|
# New API
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
//"os"
|
//"os"
|
||||||
//"strconv"
|
//"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -17,6 +18,24 @@ var Footer = ""
|
|||||||
var Logo = ""
|
var Logo = ""
|
||||||
var TopUpLink = ""
|
var TopUpLink = ""
|
||||||
|
|
||||||
|
var themeValue atomic.Value // stores string; safe for concurrent read/write
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
themeValue.Store("classic")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTheme() string {
|
||||||
|
return themeValue.Load().(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTheme updates the frontend theme atomically.
|
||||||
|
// Only "default" and "classic" are accepted; other values are silently ignored.
|
||||||
|
func SetTheme(t string) {
|
||||||
|
if t == "default" || t == "classic" {
|
||||||
|
themeValue.Store(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// var ChatLink = ""
|
// var ChatLink = ""
|
||||||
// var ChatLink2 = ""
|
// var ChatLink2 = ""
|
||||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||||
|
|||||||
@ -41,3 +41,29 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
|||||||
FileSystem: http.FS(efs),
|
FileSystem: http.FS(efs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// themeAwareFileSystem delegates to the appropriate embedded FS based on
|
||||||
|
// the current theme (via GetTheme). This enables runtime theme switching
|
||||||
|
// without restarting the server.
|
||||||
|
type themeAwareFileSystem struct {
|
||||||
|
defaultFS static.ServeFileSystem
|
||||||
|
classicFS static.ServeFileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
|
||||||
|
if GetTheme() == "classic" {
|
||||||
|
return t.classicFS.Exists(prefix, path)
|
||||||
|
}
|
||||||
|
return t.defaultFS.Exists(prefix, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
|
||||||
|
if GetTheme() == "classic" {
|
||||||
|
return t.classicFS.Open(name)
|
||||||
|
}
|
||||||
|
return t.defaultFS.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
|
||||||
|
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
|
||||||
|
}
|
||||||
|
|||||||
223
controller/discord.go
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscordResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscordUser struct {
|
||||||
|
UID string `json:"id"`
|
||||||
|
ID string `json:"username"`
|
||||||
|
Name string `json:"global_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("无效的参数")
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("client_id", system_setting.GetDiscordSettings().ClientId)
|
||||||
|
values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret)
|
||||||
|
values.Set("code", code)
|
||||||
|
values.Set("grant_type", "authorization_code")
|
||||||
|
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
|
||||||
|
formData := values.Encode()
|
||||||
|
req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var discordResponse DiscordResponse
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&discordResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if discordResponse.AccessToken == "" {
|
||||||
|
common.SysError("Discord 获取 Token 失败,请检查设置!")
|
||||||
|
return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
|
||||||
|
res2, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res2.Body.Close()
|
||||||
|
if res2.StatusCode != http.StatusOK {
|
||||||
|
common.SysError("Discord 获取用户信息失败!请检查设置!")
|
||||||
|
return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
|
||||||
|
}
|
||||||
|
|
||||||
|
var discordUser DiscordUser
|
||||||
|
err = json.NewDecoder(res2.Body).Decode(&discordUser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if discordUser.UID == "" || discordUser.ID == "" {
|
||||||
|
common.SysError("Discord 获取用户信息为空!请检查设置!")
|
||||||
|
return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
|
||||||
|
}
|
||||||
|
return &discordUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscordOAuth(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "state is empty or not same",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := session.Get("username")
|
||||||
|
if username != nil {
|
||||||
|
DiscordBind(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !system_setting.GetDiscordSettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
discordUser, err := getDiscordUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
DiscordId: discordUser.UID,
|
||||||
|
}
|
||||||
|
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||||
|
err := user.FillUserByDiscordId()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if common.RegisterEnabled {
|
||||||
|
if discordUser.ID != "" {
|
||||||
|
user.Username = discordUser.ID
|
||||||
|
} else {
|
||||||
|
user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||||
|
}
|
||||||
|
if discordUser.Name != "" {
|
||||||
|
user.DisplayName = discordUser.Name
|
||||||
|
} else {
|
||||||
|
user.DisplayName = "Discord User"
|
||||||
|
}
|
||||||
|
err := user.Insert(0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员关闭了新用户注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "用户已被封禁",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupLogin(&user, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscordBind(c *gin.Context) {
|
||||||
|
if !system_setting.GetDiscordSettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
discordUser, err := getDiscordUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
DiscordId: discordUser.UID,
|
||||||
|
}
|
||||||
|
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该 Discord 账户已被绑定",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session := sessions.Default(c)
|
||||||
|
id := session.Get("id")
|
||||||
|
user.Id = id.(int)
|
||||||
|
err = user.FillUserById()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.DiscordId = discordUser.UID
|
||||||
|
err = user.Update(false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "bind",
|
||||||
|
})
|
||||||
|
}
|
||||||
220
controller/github.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitHubOAuthResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GitHubUser struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("无效的参数")
|
||||||
|
}
|
||||||
|
values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
|
||||||
|
jsonData, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var oAuthResponse GitHubOAuthResponse
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
|
||||||
|
res2, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res2.Body.Close()
|
||||||
|
var githubUser GitHubUser
|
||||||
|
err = json.NewDecoder(res2.Body).Decode(&githubUser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if githubUser.Login == "" {
|
||||||
|
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
|
||||||
|
}
|
||||||
|
return &githubUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GitHubOAuth(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "state is empty or not same",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := session.Get("username")
|
||||||
|
if username != nil {
|
||||||
|
GitHubBind(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.GitHubOAuthEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
githubUser, err := getGitHubUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
GitHubId: githubUser.Login,
|
||||||
|
}
|
||||||
|
// IsGitHubIdAlreadyTaken is unscoped
|
||||||
|
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||||
|
// FillUserByGitHubId is scoped
|
||||||
|
err := user.FillUserByGitHubId()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// if user.Id == 0 , user has been deleted
|
||||||
|
if user.Id == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户已注销",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if common.RegisterEnabled {
|
||||||
|
user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||||
|
if githubUser.Name != "" {
|
||||||
|
user.DisplayName = githubUser.Name
|
||||||
|
} else {
|
||||||
|
user.DisplayName = "GitHub User"
|
||||||
|
}
|
||||||
|
user.Email = githubUser.Email
|
||||||
|
user.Role = common.RoleCommonUser
|
||||||
|
user.Status = common.UserStatusEnabled
|
||||||
|
affCode := session.Get("aff")
|
||||||
|
inviterId := 0
|
||||||
|
if affCode != nil {
|
||||||
|
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.Insert(inviterId); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员关闭了新用户注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "用户已被封禁",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupLogin(&user, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GitHubBind(c *gin.Context) {
|
||||||
|
if !common.GitHubOAuthEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
githubUser, err := getGitHubUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
GitHubId: githubUser.Login,
|
||||||
|
}
|
||||||
|
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该 GitHub 账户已被绑定",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session := sessions.Default(c)
|
||||||
|
id := session.Get("id")
|
||||||
|
// id := c.GetInt("id") // critical bug!
|
||||||
|
user.Id = id.(int)
|
||||||
|
err = user.FillUserById()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.GitHubId = githubUser.Login
|
||||||
|
err = user.Update(false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "bind",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
268
controller/linuxdo.go
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinuxdoUser struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
TrustLevel int `json:"trust_level"`
|
||||||
|
Silenced bool `json:"silenced"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LinuxDoBind(c *gin.Context) {
|
||||||
|
if !common.LinuxDOOAuthEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 Linux DO 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := c.Query("code")
|
||||||
|
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := model.User{
|
||||||
|
LinuxDOId: strconv.Itoa(linuxdoUser.Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该 Linux DO 账户已被绑定",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
id := session.Get("id")
|
||||||
|
user.Id = id.(int)
|
||||||
|
|
||||||
|
err = user.FillUserById()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
|
||||||
|
err = user.Update(false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "bind",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("invalid code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token using Basic auth
|
||||||
|
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
|
||||||
|
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
|
||||||
|
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||||
|
|
||||||
|
// Get redirect URI from request
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host)
|
||||||
|
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("grant_type", "authorization_code")
|
||||||
|
data.Set("code", code)
|
||||||
|
data.Set("redirect_uri", redirectURI)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", basicAuth)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := http.Client{Timeout: 5 * time.Second}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to connect to Linux DO server")
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var tokenRes struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenRes.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("failed to get access token: %s", tokenRes.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
|
||||||
|
req, err = http.NewRequest("GET", userEndpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
res2, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("failed to get user info from Linux DO")
|
||||||
|
}
|
||||||
|
defer res2.Body.Close()
|
||||||
|
|
||||||
|
var linuxdoUser LinuxdoUser
|
||||||
|
if err := json.NewDecoder(res2.Body).Decode(&linuxdoUser); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if linuxdoUser.Id == 0 {
|
||||||
|
return nil, errors.New("invalid user info returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &linuxdoUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LinuxdoOAuth(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
|
||||||
|
errorCode := c.Query("error")
|
||||||
|
if errorCode != "" {
|
||||||
|
errorDescription := c.Query("error_description")
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": errorDescription,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "state is empty or not same",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := session.Get("username")
|
||||||
|
if username != nil {
|
||||||
|
LinuxDoBind(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.LinuxDOOAuthEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 Linux DO 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := c.Query("code")
|
||||||
|
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := model.User{
|
||||||
|
LinuxDOId: strconv.Itoa(linuxdoUser.Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
|
||||||
|
err := user.FillUserByLinuxDOId()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Id == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户已注销",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if common.RegisterEnabled {
|
||||||
|
if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
|
||||||
|
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||||
|
user.DisplayName = linuxdoUser.Name
|
||||||
|
user.Role = common.RoleCommonUser
|
||||||
|
user.Status = common.UserStatusEnabled
|
||||||
|
|
||||||
|
affCode := session.Get("aff")
|
||||||
|
inviterId := 0
|
||||||
|
if affCode != nil {
|
||||||
|
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.Insert(inviterId); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员关闭了新用户注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "用户已被封禁",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLogin(&user, c)
|
||||||
|
}
|
||||||
@ -61,6 +61,7 @@ func GetStatus(c *gin.Context) {
|
|||||||
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||||
"telegram_oauth": common.TelegramOAuthEnabled,
|
"telegram_oauth": common.TelegramOAuthEnabled,
|
||||||
"telegram_bot_name": common.TelegramBotName,
|
"telegram_bot_name": common.TelegramBotName,
|
||||||
|
"theme": system_setting.GetThemeSettings().Frontend,
|
||||||
"system_name": common.SystemName,
|
"system_name": common.SystemName,
|
||||||
"logo": common.Logo,
|
"logo": common.Logo,
|
||||||
"footer_html": common.Footer,
|
"footer_html": common.Footer,
|
||||||
|
|||||||
228
controller/oidc.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OidcResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcUser struct {
|
||||||
|
OpenID string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("无效的参数")
|
||||||
|
}
|
||||||
|
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("client_id", system_setting.GetOIDCSettings().ClientId)
|
||||||
|
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
|
||||||
|
values.Set("code", code)
|
||||||
|
values.Set("grant_type", "authorization_code")
|
||||||
|
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
|
||||||
|
formData := values.Encode()
|
||||||
|
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
client := http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var oidcResponse OidcResponse
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&oidcResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if oidcResponse.AccessToken == "" {
|
||||||
|
common.SysLog("OIDC 获取 Token 失败,请检查设置!")
|
||||||
|
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", system_setting.GetOIDCSettings().UserInfoEndpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken)
|
||||||
|
res2, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(err.Error())
|
||||||
|
return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
|
||||||
|
}
|
||||||
|
defer res2.Body.Close()
|
||||||
|
if res2.StatusCode != http.StatusOK {
|
||||||
|
common.SysLog("OIDC 获取用户信息失败!请检查设置!")
|
||||||
|
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcUser OidcUser
|
||||||
|
err = json.NewDecoder(res2.Body).Decode(&oidcUser)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if oidcUser.OpenID == "" || oidcUser.Email == "" {
|
||||||
|
common.SysLog("OIDC 获取用户信息为空!请检查设置!")
|
||||||
|
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
|
||||||
|
}
|
||||||
|
return &oidcUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func OidcAuth(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "state is empty or not same",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := session.Get("username")
|
||||||
|
if username != nil {
|
||||||
|
OidcBind(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !system_setting.GetOIDCSettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 OIDC 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
oidcUser, err := getOidcUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
OidcId: oidcUser.OpenID,
|
||||||
|
}
|
||||||
|
if model.IsOidcIdAlreadyTaken(user.OidcId) {
|
||||||
|
err := user.FillUserByOidcId()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if common.RegisterEnabled {
|
||||||
|
user.Email = oidcUser.Email
|
||||||
|
if oidcUser.PreferredUsername != "" {
|
||||||
|
user.Username = oidcUser.PreferredUsername
|
||||||
|
} else {
|
||||||
|
user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||||
|
}
|
||||||
|
if oidcUser.Name != "" {
|
||||||
|
user.DisplayName = oidcUser.Name
|
||||||
|
} else {
|
||||||
|
user.DisplayName = "OIDC User"
|
||||||
|
}
|
||||||
|
err := user.Insert(0)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员关闭了新用户注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "用户已被封禁",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupLogin(&user, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OidcBind(c *gin.Context) {
|
||||||
|
if !system_setting.GetOIDCSettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未开启通过 OIDC 登录以及注册",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := c.Query("code")
|
||||||
|
oidcUser, err := getOidcUserInfoByCode(code)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
OidcId: oidcUser.OpenID,
|
||||||
|
}
|
||||||
|
if model.IsOidcIdAlreadyTaken(user.OidcId) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该 OIDC 账户已被绑定",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session := sessions.Default(c)
|
||||||
|
id := session.Get("id")
|
||||||
|
// id := c.GetInt("id") // critical bug!
|
||||||
|
user.Id = id.(int)
|
||||||
|
err = user.FillUserById()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.OidcId = oidcUser.OpenID
|
||||||
|
err = user.Update(false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "bind",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
@ -198,6 +198,14 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "theme.frontend":
|
||||||
|
if option.Value != "default" && option.Value != "classic" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
case "GroupRatio":
|
case "GroupRatio":
|
||||||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
313
controller/task_video.go
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/relay"
|
||||||
|
"github.com/QuantumNous/new-api/relay/channel"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||||
|
for channelId, taskIds := range taskChannelM {
|
||||||
|
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
||||||
|
if len(taskIds) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cacheGetChannel, err := model.CacheGetChannel(channelId)
|
||||||
|
if err != nil {
|
||||||
|
errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{
|
||||||
|
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
|
||||||
|
"status": "FAILURE",
|
||||||
|
"progress": "100%",
|
||||||
|
})
|
||||||
|
if errUpdate != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
||||||
|
}
|
||||||
|
adaptor := relay.GetTaskAdaptor(platform)
|
||||||
|
if adaptor == nil {
|
||||||
|
return fmt.Errorf("video adaptor not found")
|
||||||
|
}
|
||||||
|
info := &relaycommon.RelayInfo{}
|
||||||
|
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||||
|
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||||
|
}
|
||||||
|
info.ApiKey = cacheGetChannel.Key
|
||||||
|
adaptor.Init(info)
|
||||||
|
for _, taskId := range taskIds {
|
||||||
|
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error {
|
||||||
|
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||||
|
if channel.GetBaseURL() != "" {
|
||||||
|
baseURL = channel.GetBaseURL()
|
||||||
|
}
|
||||||
|
proxy := channel.GetSetting().Proxy
|
||||||
|
|
||||||
|
task := taskM[taskId]
|
||||||
|
if task == nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
||||||
|
return fmt.Errorf("task %s not found", taskId)
|
||||||
|
}
|
||||||
|
key := channel.Key
|
||||||
|
|
||||||
|
privateData := task.PrivateData
|
||||||
|
if privateData.Key != "" {
|
||||||
|
key = privateData.Key
|
||||||
|
}
|
||||||
|
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
||||||
|
"task_id": taskId,
|
||||||
|
"action": task.Action,
|
||||||
|
}, proxy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
||||||
|
}
|
||||||
|
//if resp.StatusCode != http.StatusOK {
|
||||||
|
//return fmt.Errorf("get Video Task status code: %d", resp.StatusCode)
|
||||||
|
//}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||||
|
|
||||||
|
taskResult := &relaycommon.TaskInfo{}
|
||||||
|
// try parse as New API response format
|
||||||
|
var responseItems dto.TaskResponse[model.Task]
|
||||||
|
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||||
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||||
|
t := responseItems.Data
|
||||||
|
taskResult.TaskID = t.TaskID
|
||||||
|
taskResult.Status = string(t.Status)
|
||||||
|
taskResult.Url = t.FailReason
|
||||||
|
taskResult.Progress = t.Progress
|
||||||
|
taskResult.Reason = t.FailReason
|
||||||
|
task.Data = t.Data
|
||||||
|
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
|
||||||
|
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||||
|
} else {
|
||||||
|
task.Data = redactVideoResponseBody(responseBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if taskResult.Status == "" {
|
||||||
|
//return fmt.Errorf("task %s status is empty", taskId)
|
||||||
|
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录原本的状态,防止重复退款
|
||||||
|
shouldRefund := false
|
||||||
|
quota := task.Quota
|
||||||
|
preStatus := task.Status
|
||||||
|
|
||||||
|
task.Status = model.TaskStatus(taskResult.Status)
|
||||||
|
switch taskResult.Status {
|
||||||
|
case model.TaskStatusSubmitted:
|
||||||
|
task.Progress = "10%"
|
||||||
|
case model.TaskStatusQueued:
|
||||||
|
task.Progress = "20%"
|
||||||
|
case model.TaskStatusInProgress:
|
||||||
|
task.Progress = "30%"
|
||||||
|
if task.StartTime == 0 {
|
||||||
|
task.StartTime = now
|
||||||
|
}
|
||||||
|
case model.TaskStatusSuccess:
|
||||||
|
task.Progress = "100%"
|
||||||
|
if task.FinishTime == 0 {
|
||||||
|
task.FinishTime = now
|
||||||
|
}
|
||||||
|
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
|
||||||
|
task.FailReason = taskResult.Url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
|
||||||
|
if taskResult.TotalTokens > 0 {
|
||||||
|
// 获取模型名称
|
||||||
|
var taskData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(task.Data, &taskData); err == nil {
|
||||||
|
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
|
||||||
|
// 获取模型价格和倍率
|
||||||
|
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
|
||||||
|
// 只有配置了倍率(非固定价格)时才按 token 重新计费
|
||||||
|
if hasRatioSetting && modelRatio > 0 {
|
||||||
|
// 获取用户和组的倍率信息
|
||||||
|
group := task.Group
|
||||||
|
if group == "" {
|
||||||
|
user, err := model.GetUserById(task.UserId, false)
|
||||||
|
if err == nil {
|
||||||
|
group = user.Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if group != "" {
|
||||||
|
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||||
|
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
|
||||||
|
|
||||||
|
var finalGroupRatio float64
|
||||||
|
if hasUserGroupRatio {
|
||||||
|
finalGroupRatio = userGroupRatio
|
||||||
|
} else {
|
||||||
|
finalGroupRatio = groupRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||||
|
actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
|
||||||
|
|
||||||
|
// 计算差额
|
||||||
|
preConsumedQuota := task.Quota
|
||||||
|
quotaDelta := actualQuota - preConsumedQuota
|
||||||
|
|
||||||
|
if quotaDelta > 0 {
|
||||||
|
// 需要补扣费
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||||
|
task.TaskID,
|
||||||
|
logger.LogQuota(quotaDelta),
|
||||||
|
logger.LogQuota(actualQuota),
|
||||||
|
logger.LogQuota(preConsumedQuota),
|
||||||
|
taskResult.TotalTokens,
|
||||||
|
))
|
||||||
|
if err := model.DecreaseUserQuota(task.UserId, quotaDelta, false); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
|
||||||
|
} else {
|
||||||
|
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||||||
|
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||||||
|
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||||
|
|
||||||
|
// 记录消费日志
|
||||||
|
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
|
||||||
|
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||||
|
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
|
}
|
||||||
|
} else if quotaDelta < 0 {
|
||||||
|
// 需要退还多扣的费用
|
||||||
|
refundQuota := -quotaDelta
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||||
|
task.TaskID,
|
||||||
|
logger.LogQuota(refundQuota),
|
||||||
|
logger.LogQuota(actualQuota),
|
||||||
|
logger.LogQuota(preConsumedQuota),
|
||||||
|
taskResult.TotalTokens,
|
||||||
|
))
|
||||||
|
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
|
||||||
|
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
|
||||||
|
} else {
|
||||||
|
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||||
|
|
||||||
|
// 记录退款日志
|
||||||
|
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
|
||||||
|
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||||
|
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// quotaDelta == 0, 预扣费刚好准确
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
|
||||||
|
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case model.TaskStatusFailure:
|
||||||
|
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
|
||||||
|
task.Status = model.TaskStatusFailure
|
||||||
|
task.Progress = "100%"
|
||||||
|
if task.FinishTime == 0 {
|
||||||
|
task.FinishTime = now
|
||||||
|
}
|
||||||
|
task.FailReason = taskResult.Reason
|
||||||
|
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
||||||
|
taskResult.Progress = "100%"
|
||||||
|
if quota != 0 {
|
||||||
|
if preStatus != model.TaskStatusFailure {
|
||||||
|
shouldRefund = true
|
||||||
|
} else {
|
||||||
|
logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
|
||||||
|
}
|
||||||
|
if taskResult.Progress != "" {
|
||||||
|
task.Progress = taskResult.Progress
|
||||||
|
}
|
||||||
|
if err := task.Update(); err != nil {
|
||||||
|
common.SysLog("UpdateVideoTask task error: " + err.Error())
|
||||||
|
shouldRefund = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRefund {
|
||||||
|
// 任务失败且之前状态不是失败才退还额度,防止重复退还
|
||||||
|
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
|
||||||
|
logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
|
||||||
|
}
|
||||||
|
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
|
||||||
|
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactVideoResponseBody(body []byte) []byte {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
resp, _ := m["response"].(map[string]any)
|
||||||
|
if resp != nil {
|
||||||
|
delete(resp, "bytesBase64Encoded")
|
||||||
|
if v, ok := resp["video"].(string); ok {
|
||||||
|
resp["video"] = truncateBase64(v)
|
||||||
|
}
|
||||||
|
if vs, ok := resp["videos"].([]any); ok {
|
||||||
|
for i := range vs {
|
||||||
|
if vm, ok := vs[i].(map[string]any); ok {
|
||||||
|
delete(vm, "bytesBase64Encoded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateBase64(s string) string {
|
||||||
|
const maxKeep = 256
|
||||||
|
if len(s) <= maxKeep {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxKeep] + "..."
|
||||||
|
}
|
||||||
73
docker-compose.dev.yml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Frontend Development - Backend built from local source
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# 1. docker compose -f docker-compose.dev.yml up -d
|
||||||
|
# 2. cd web && bun install && bun run dev
|
||||||
|
# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000)
|
||||||
|
#
|
||||||
|
# Rebuild backend after Go code changes:
|
||||||
|
# docker compose -f docker-compose.dev.yml up -d --build new-api
|
||||||
|
#
|
||||||
|
# Stop:
|
||||||
|
# docker compose -f docker-compose.dev.yml down
|
||||||
|
#
|
||||||
|
# Reset data:
|
||||||
|
# docker compose -f docker-compose.dev.yml down -v
|
||||||
|
|
||||||
|
services:
|
||||||
|
new-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
image: new-api-dev:local
|
||||||
|
container_name: new-api-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- dev_data:/data
|
||||||
|
environment:
|
||||||
|
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api
|
||||||
|
- REDIS_CONN_STRING=redis://redis
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
- BATCH_UPDATE_ENABLED=true
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- dev-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: new-api-dev-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- dev-network
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: new-api-dev-pg
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: root
|
||||||
|
POSTGRES_PASSWORD: 123456
|
||||||
|
POSTGRES_DB: new-api
|
||||||
|
volumes:
|
||||||
|
- dev_pg_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- dev-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U root -d new-api"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dev_data:
|
||||||
|
dev_pg_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dev-network:
|
||||||
|
driver: bridge
|
||||||
@ -279,8 +279,8 @@ type Message struct {
|
|||||||
Content any `json:"content"`
|
Content any `json:"content"`
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Prefix *bool `json:"prefix,omitempty"`
|
Prefix *bool `json:"prefix,omitempty"`
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent *string `json:"reasoning_content,omitempty"`
|
||||||
Reasoning string `json:"reasoning,omitempty"`
|
Reasoning *string `json:"reasoning,omitempty"`
|
||||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||||
ToolCallId string `json:"tool_call_id,omitempty"`
|
ToolCallId string `json:"tool_call_id,omitempty"`
|
||||||
parsedContent []MediaContent
|
parsedContent []MediaContent
|
||||||
@ -431,6 +431,16 @@ const (
|
|||||||
//ContentTypeAudioUrl = "audio_url"
|
//ContentTypeAudioUrl = "audio_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (m *Message) GetReasoningContent() string {
|
||||||
|
if m.ReasoningContent == nil && m.Reasoning == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if m.ReasoningContent != nil {
|
||||||
|
return *m.ReasoningContent
|
||||||
|
}
|
||||||
|
return *m.Reasoning
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Message) GetPrefix() bool {
|
func (m *Message) GetPrefix() bool {
|
||||||
if m.Prefix == nil {
|
if m.Prefix == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
29
main.go
@ -34,12 +34,18 @@ import (
|
|||||||
_ "net/http/pprof"
|
_ "net/http/pprof"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed web/dist
|
//go:embed web/default/dist
|
||||||
var buildFS embed.FS
|
var buildFS embed.FS
|
||||||
|
|
||||||
//go:embed web/dist/index.html
|
//go:embed web/default/dist/index.html
|
||||||
var indexPage []byte
|
var indexPage []byte
|
||||||
|
|
||||||
|
//go:embed web/classic/dist
|
||||||
|
var classicBuildFS embed.FS
|
||||||
|
|
||||||
|
//go:embed web/classic/dist/index.html
|
||||||
|
var classicIndexPage []byte
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@ -183,7 +189,12 @@ func main() {
|
|||||||
InjectGoogleAnalytics()
|
InjectGoogleAnalytics()
|
||||||
|
|
||||||
// 设置路由
|
// 设置路由
|
||||||
router.SetRouter(server, buildFS, indexPage)
|
router.SetRouter(server, router.ThemeAssets{
|
||||||
|
DefaultBuildFS: buildFS,
|
||||||
|
DefaultIndexPage: indexPage,
|
||||||
|
ClassicBuildFS: classicBuildFS,
|
||||||
|
ClassicIndexPage: classicIndexPage,
|
||||||
|
})
|
||||||
var port = os.Getenv("PORT")
|
var port = os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = strconv.Itoa(*common.Port)
|
port = strconv.Itoa(*common.Port)
|
||||||
@ -213,8 +224,10 @@ func InjectUmamiAnalytics() {
|
|||||||
analyticsInjectBuilder.WriteString("\"></script>")
|
analyticsInjectBuilder.WriteString("\"></script>")
|
||||||
}
|
}
|
||||||
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
|
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
|
||||||
analyticsInject := analyticsInjectBuilder.String()
|
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
|
placeholder := []byte("<!--umami-->\n")
|
||||||
|
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||||
|
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InjectGoogleAnalytics() {
|
func InjectGoogleAnalytics() {
|
||||||
@ -235,8 +248,10 @@ func InjectGoogleAnalytics() {
|
|||||||
analyticsInjectBuilder.WriteString("</script>")
|
analyticsInjectBuilder.WriteString("</script>")
|
||||||
}
|
}
|
||||||
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
|
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
|
||||||
analyticsInject := analyticsInjectBuilder.String()
|
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
|
placeholder := []byte("<!--Google Analytics-->\n")
|
||||||
|
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||||
|
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitResources() error {
|
func InitResources() error {
|
||||||
|
|||||||
31
makefile
@ -1,14 +1,35 @@
|
|||||||
FRONTEND_DIR = ./web
|
FRONTEND_DIR = ./web/default
|
||||||
|
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||||
BACKEND_DIR = .
|
BACKEND_DIR = .
|
||||||
|
|
||||||
.PHONY: all build-frontend start-backend
|
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||||
|
|
||||||
all: build-frontend start-backend
|
all: build-all-frontends start-backend
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@echo "Building frontend..."
|
@echo "Building default frontend..."
|
||||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||||
|
|
||||||
|
build-frontend-classic:
|
||||||
|
@echo "Building classic frontend..."
|
||||||
|
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||||
|
|
||||||
|
build-all-frontends: build-frontend build-frontend-classic
|
||||||
|
|
||||||
start-backend:
|
start-backend:
|
||||||
@echo "Starting backend dev server..."
|
@echo "Starting backend dev server..."
|
||||||
@cd $(BACKEND_DIR) && go run main.go &
|
@cd $(BACKEND_DIR) && go run main.go &
|
||||||
|
|
||||||
|
dev-api:
|
||||||
|
@echo "Starting backend services (docker)..."
|
||||||
|
@docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
dev-web:
|
||||||
|
@echo "Starting frontend dev server..."
|
||||||
|
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||||
|
|
||||||
|
dev-web-classic:
|
||||||
|
@echo "Starting classic frontend dev server..."
|
||||||
|
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||||
|
|
||||||
|
dev: dev-api dev-web
|
||||||
|
|||||||
@ -581,6 +581,8 @@ func handleConfigUpdate(key, value string) bool {
|
|||||||
} else if configName == "billing_setting" {
|
} else if configName == "billing_setting" {
|
||||||
InvalidatePricingCache()
|
InvalidatePricingCache()
|
||||||
ratio_setting.InvalidateExposedDataCache()
|
ratio_setting.InvalidateExposedDataCache()
|
||||||
|
} else if configName == "theme" {
|
||||||
|
system_setting.UpdateAndSyncTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true // 已处理
|
return true // 已处理
|
||||||
|
|||||||
@ -416,6 +416,17 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) {
|
|||||||
return result.RowsAffected > 0, nil
|
return result.RowsAffected > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskBulkUpdate performs an unconditional bulk UPDATE by upstream task_id strings.
|
||||||
|
// Same caveats as TaskBulkUpdateByID — no CAS guard.
|
||||||
|
func TaskBulkUpdate(taskIds []string, params map[string]any) error {
|
||||||
|
if len(taskIds) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Model(&Task{}).
|
||||||
|
Where("task_id in (?)", taskIds).
|
||||||
|
Updates(params).Error
|
||||||
|
}
|
||||||
|
|
||||||
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
|
// TaskBulkUpdateByID performs an unconditional bulk UPDATE by primary key IDs.
|
||||||
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
|
// WARNING: This function has NO CAS (Compare-And-Swap) guard — it will overwrite
|
||||||
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
|
// any concurrent status changes. DO NOT use in billing/quota lifecycle flows
|
||||||
|
|||||||
@ -567,12 +567,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
|
|||||||
}
|
}
|
||||||
choice.SetStringContent(responseText)
|
choice.SetStringContent(responseText)
|
||||||
if len(responseThinking) > 0 {
|
if len(responseThinking) > 0 {
|
||||||
choice.ReasoningContent = responseThinking
|
choice.ReasoningContent = &responseThinking
|
||||||
}
|
}
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
choice.Message.SetToolCalls(tools)
|
choice.Message.SetToolCalls(tools)
|
||||||
}
|
}
|
||||||
choice.Message.ReasoningContent = thinkingContent
|
if thinkingContent != "" {
|
||||||
|
choice.Message.ReasoningContent = &thinkingContent
|
||||||
|
}
|
||||||
fullTextResponse.Model = claudeResponse.Model
|
fullTextResponse.Model = claudeResponse.Model
|
||||||
choices = append(choices, choice)
|
choices = append(choices, choice)
|
||||||
fullTextResponse.Choices = choices
|
fullTextResponse.Choices = choices
|
||||||
|
|||||||
@ -1097,7 +1097,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
|||||||
toolCalls = append(toolCalls, *call)
|
toolCalls = append(toolCalls, *call)
|
||||||
}
|
}
|
||||||
} else if part.Thought {
|
} else if part.Thought {
|
||||||
choice.Message.ReasoningContent = part.Text
|
choice.Message.ReasoningContent = &part.Text
|
||||||
} else {
|
} else {
|
||||||
if part.ExecutableCode != nil {
|
if part.ExecutableCode != nil {
|
||||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
||||||
|
|||||||
@ -273,7 +273,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
|||||||
|
|
||||||
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||||
if rc := reasoningBuilder.String(); rc != "" {
|
if rc := reasoningBuilder.String(); rc != "" {
|
||||||
msg.ReasoningContent = rc
|
msg.ReasoningContent = &rc
|
||||||
}
|
}
|
||||||
full := dto.OpenAITextResponse{
|
full := dto.OpenAITextResponse{
|
||||||
Id: common.GetUUID(),
|
Id: common.GetUUID(),
|
||||||
|
|||||||
@ -245,7 +245,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
|||||||
completionTokens := simpleResponse.Usage.CompletionTokens
|
completionTokens := simpleResponse.Usage.CompletionTokens
|
||||||
if completionTokens == 0 {
|
if completionTokens == 0 {
|
||||||
for _, choice := range simpleResponse.Choices {
|
for _, choice := range simpleResponse.Choices {
|
||||||
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
|
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
|
||||||
completionTokens += ctkm
|
completionTokens += ctkm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,20 +95,7 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
|
|||||||
if strings.TrimSpace(region) == "" {
|
if strings.TrimSpace(region) == "" {
|
||||||
region = "global"
|
region = "global"
|
||||||
}
|
}
|
||||||
if region == "global" {
|
return vertexcore.BuildGoogleModelURL(a.baseURL, vertexcore.DefaultAPIVersion, adc.ProjectID, region, modelName, "predictLongRunning"), nil
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning",
|
|
||||||
adc.ProjectID,
|
|
||||||
modelName,
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
|
|
||||||
region,
|
|
||||||
adc.ProjectID,
|
|
||||||
region,
|
|
||||||
modelName,
|
|
||||||
), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildRequestHeader sets required headers.
|
// BuildRequestHeader sets required headers.
|
||||||
@ -238,6 +225,22 @@ func (a *TaskAdaptor) GetModelList() []string {
|
|||||||
}
|
}
|
||||||
func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
|
func (a *TaskAdaptor) GetChannelName() string { return "vertex" }
|
||||||
|
|
||||||
|
func buildFetchOperationURL(baseURL, upstreamName string) (string, error) {
|
||||||
|
region := extractRegionFromOperationName(upstreamName)
|
||||||
|
if region == "" {
|
||||||
|
region = "us-central1"
|
||||||
|
}
|
||||||
|
project := extractProjectFromOperationName(upstreamName)
|
||||||
|
modelName := extractModelFromOperationName(upstreamName)
|
||||||
|
if strings.TrimSpace(modelName) == "" {
|
||||||
|
return "", fmt.Errorf("cannot extract model from operation name")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(project) == "" {
|
||||||
|
return "", fmt.Errorf("cannot extract project from operation name")
|
||||||
|
}
|
||||||
|
return vertexcore.BuildGoogleModelURL(baseURL, vertexcore.DefaultAPIVersion, project, region, modelName, "fetchPredictOperation"), nil
|
||||||
|
}
|
||||||
|
|
||||||
// FetchTask fetch task status
|
// FetchTask fetch task status
|
||||||
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
|
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
|
||||||
taskID, ok := body["task_id"].(string)
|
taskID, ok := body["task_id"].(string)
|
||||||
@ -248,20 +251,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode task_id failed: %w", err)
|
return nil, fmt.Errorf("decode task_id failed: %w", err)
|
||||||
}
|
}
|
||||||
region := extractRegionFromOperationName(upstreamName)
|
url, err := buildFetchOperationURL(baseUrl, upstreamName)
|
||||||
if region == "" {
|
if err != nil {
|
||||||
region = "us-central1"
|
return nil, err
|
||||||
}
|
|
||||||
project := extractProjectFromOperationName(upstreamName)
|
|
||||||
modelName := extractModelFromOperationName(upstreamName)
|
|
||||||
if project == "" || modelName == "" {
|
|
||||||
return nil, fmt.Errorf("cannot extract project/model from operation name")
|
|
||||||
}
|
|
||||||
var url string
|
|
||||||
if region == "global" {
|
|
||||||
url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName)
|
|
||||||
} else {
|
|
||||||
url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName)
|
|
||||||
}
|
}
|
||||||
payload := fetchOperationPayload{OperationName: upstreamName}
|
payload := fetchOperationPayload{OperationName: upstreamName}
|
||||||
data, err := common.Marshal(payload)
|
data, err := common.Marshal(payload)
|
||||||
|
|||||||
@ -134,47 +134,11 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
|||||||
a.AccountCredentials = *adc
|
a.AccountCredentials = *adc
|
||||||
|
|
||||||
if a.RequestMode == RequestModeGemini {
|
if a.RequestMode == RequestModeGemini {
|
||||||
if region == "global" {
|
return BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
|
|
||||||
adc.ProjectID,
|
|
||||||
modelName,
|
|
||||||
suffix,
|
|
||||||
), nil
|
|
||||||
} else {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
|
||||||
region,
|
|
||||||
adc.ProjectID,
|
|
||||||
region,
|
|
||||||
modelName,
|
|
||||||
suffix,
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
} else if a.RequestMode == RequestModeClaude {
|
} else if a.RequestMode == RequestModeClaude {
|
||||||
if region == "global" {
|
return BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s",
|
|
||||||
adc.ProjectID,
|
|
||||||
modelName,
|
|
||||||
suffix,
|
|
||||||
), nil
|
|
||||||
} else {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s",
|
|
||||||
region,
|
|
||||||
adc.ProjectID,
|
|
||||||
region,
|
|
||||||
modelName,
|
|
||||||
suffix,
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
} else if a.RequestMode == RequestModeOpenSource {
|
} else if a.RequestMode == RequestModeOpenSource {
|
||||||
return fmt.Sprintf(
|
return BuildOpenSourceChatCompletionsURL(info.ChannelBaseUrl, adc.ProjectID, region), nil
|
||||||
"https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
|
||||||
adc.ProjectID,
|
|
||||||
region,
|
|
||||||
), nil
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var keyPrefix string
|
var keyPrefix string
|
||||||
@ -183,20 +147,17 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
|||||||
} else {
|
} else {
|
||||||
keyPrefix = "?"
|
keyPrefix = "?"
|
||||||
}
|
}
|
||||||
if region == "global" {
|
if a.RequestMode == RequestModeGemini {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
|
"%s%skey=%s",
|
||||||
modelName,
|
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
|
||||||
suffix,
|
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
info.ApiKey,
|
info.ApiKey,
|
||||||
), nil
|
), nil
|
||||||
} else {
|
} else if a.RequestMode == RequestModeClaude {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
|
"%s%skey=%s",
|
||||||
region,
|
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
|
||||||
modelName,
|
|
||||||
suffix,
|
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
info.ApiKey,
|
info.ApiKey,
|
||||||
), nil
|
), nil
|
||||||
|
|||||||
86
relay/channel/vertex/url_builder.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package vertex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultAPIVersion = "v1"
|
||||||
|
OpenSourceAPIVersion = "v1beta1"
|
||||||
|
PublisherGoogle = "google"
|
||||||
|
PublisherAnthropic = "anthropic"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeVertexBaseURL(baseURL string) string {
|
||||||
|
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVertexRegion(region string) string {
|
||||||
|
region = strings.TrimSpace(region)
|
||||||
|
if region == "" {
|
||||||
|
return "global"
|
||||||
|
}
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendVertexAPIVersion(baseURL, version string) string {
|
||||||
|
version = strings.Trim(strings.TrimSpace(version), "/")
|
||||||
|
if version == "" {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(baseURL, "/"+version) {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
return baseURL + "/" + version
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAPIBaseURL(baseURL, version, projectID, region string) string {
|
||||||
|
if normalized := normalizeVertexBaseURL(baseURL); normalized != "" {
|
||||||
|
normalized = appendVertexAPIVersion(normalized, version)
|
||||||
|
|
||||||
|
region = normalizeVertexRegion(region)
|
||||||
|
if strings.TrimSpace(projectID) != "" {
|
||||||
|
normalized = fmt.Sprintf("%s/projects/%s/locations/%s", normalized, projectID, region)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
region = normalizeVertexRegion(region)
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
if region == "global" {
|
||||||
|
return fmt.Sprintf("https://aiplatform.googleapis.com/%s", version)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s", region, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if region == "global" {
|
||||||
|
return fmt.Sprintf("https://aiplatform.googleapis.com/%s/projects/%s/locations/global", version, projectID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s/projects/%s/locations/%s", region, version, projectID, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPublisherModelURL(baseURL, version, projectID, region, publisher, modelName, action string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s/publishers/%s/models/%s:%s",
|
||||||
|
BuildAPIBaseURL(baseURL, version, projectID, region),
|
||||||
|
publisher,
|
||||||
|
modelName,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildGoogleModelURL(baseURL, version, projectID, region, modelName, action string) string {
|
||||||
|
return BuildPublisherModelURL(baseURL, version, projectID, region, PublisherGoogle, modelName, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAnthropicModelURL(baseURL, version, projectID, region, modelName, action string) string {
|
||||||
|
return BuildPublisherModelURL(baseURL, version, projectID, region, PublisherAnthropic, modelName, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildOpenSourceChatCompletionsURL(baseURL, projectID, region string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s/endpoints/openapi/chat/completions",
|
||||||
|
BuildAPIBaseURL(baseURL, OpenSourceAPIVersion, projectID, region),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -13,7 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
func SetRouter(router *gin.Engine, assets ThemeAssets) {
|
||||||
SetApiRouter(router)
|
SetApiRouter(router)
|
||||||
SetDashboardRouter(router)
|
SetDashboardRouter(router)
|
||||||
SetRelayRouter(router)
|
SetRelayRouter(router)
|
||||||
@ -24,7 +23,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
|||||||
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
|
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
|
||||||
}
|
}
|
||||||
if frontendBaseUrl == "" {
|
if frontendBaseUrl == "" {
|
||||||
SetWebRouter(router, buildFS, indexPage)
|
SetWebRouter(router, assets)
|
||||||
} else {
|
} else {
|
||||||
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
|
|||||||
@ -13,11 +13,23 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
// ThemeAssets holds the embedded frontend assets for both themes.
|
||||||
|
type ThemeAssets struct {
|
||||||
|
DefaultBuildFS embed.FS
|
||||||
|
DefaultIndexPage []byte
|
||||||
|
ClassicBuildFS embed.FS
|
||||||
|
ClassicIndexPage []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
||||||
|
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
|
||||||
|
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
|
||||||
|
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
|
||||||
|
|
||||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||||
router.Use(middleware.GlobalWebRateLimit())
|
router.Use(middleware.GlobalWebRateLimit())
|
||||||
router.Use(middleware.Cache())
|
router.Use(middleware.Cache())
|
||||||
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
|
router.Use(static.Serve("/", themeFS))
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
c.Set(middleware.RouteTagKey, "web")
|
c.Set(middleware.RouteTagKey, "web")
|
||||||
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
|
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
|
||||||
@ -25,6 +37,10 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
|
if common.GetTheme() == "classic" {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
||||||
|
} else {
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
79
service/pre_consume_quota.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/logger"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||||
|
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||||
|
gopool.Go(func() {
|
||||||
|
relayInfoCopy := *relayInfo
|
||||||
|
|
||||||
|
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||||
|
// It returns the pre-consumed quota if successful, or an error if not.
|
||||||
|
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||||
|
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
if userQuota <= 0 {
|
||||||
|
return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
if userQuota-preConsumedQuota < 0 {
|
||||||
|
return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
|
||||||
|
trustQuota := common.GetTrustQuota()
|
||||||
|
|
||||||
|
relayInfo.UserQuota = userQuota
|
||||||
|
if userQuota > trustQuota {
|
||||||
|
// 用户额度充足,判断令牌额度是否充足
|
||||||
|
if !relayInfo.TokenUnlimited {
|
||||||
|
// 非无限令牌,判断令牌额度是否充足
|
||||||
|
tokenQuota := c.GetInt("token_quota")
|
||||||
|
if tokenQuota > trustQuota {
|
||||||
|
// 令牌额度充足,信任令牌
|
||||||
|
preConsumedQuota = 0
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// in this case, we do not pre-consume quota
|
||||||
|
// because the user has enough quota
|
||||||
|
preConsumedQuota = 0
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if preConsumedQuota > 0 {
|
||||||
|
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota, false)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
|
||||||
|
}
|
||||||
|
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||||
|
return nil
|
||||||
|
}
|
||||||
32
setting/system_setting/theme.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package system_setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/setting/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ThemeSettings struct {
|
||||||
|
Frontend string `json:"frontend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeSettings = ThemeSettings{
|
||||||
|
Frontend: "classic",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
config.GlobalConfig.Register("theme", &themeSettings)
|
||||||
|
syncThemeToCommon()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncThemeToCommon() {
|
||||||
|
common.SetTheme(themeSettings.Frontend)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetThemeSettings() *ThemeSettings {
|
||||||
|
return &themeSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAndSyncTheme syncs the theme config to common after DB load.
|
||||||
|
func UpdateAndSyncTheme() {
|
||||||
|
syncThemeToCommon()
|
||||||
|
}
|
||||||
0
web/.gitignore → web/classic/.gitignore
vendored
0
web/bun.lock → web/classic/bun.lock
vendored
0
web/index.html → web/classic/index.html
vendored
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
113
web/classic/src/components/common/examples/ChannelKeyViewExample.jsx
vendored
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||||
|
import { createApiCalls } from '../../../services/secureVerification';
|
||||||
|
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||||
|
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道密钥查看组件使用示例
|
||||||
|
* 展示如何使用通用安全验证系统
|
||||||
|
*/
|
||||||
|
const ChannelKeyViewExample = ({ channelId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [keyData, setKeyData] = useState('');
|
||||||
|
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||||
|
|
||||||
|
// 使用通用安全验证 Hook
|
||||||
|
const {
|
||||||
|
isModalVisible,
|
||||||
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
startVerification,
|
||||||
|
executeVerification,
|
||||||
|
cancelVerification,
|
||||||
|
setVerificationCode,
|
||||||
|
switchVerificationMethod,
|
||||||
|
} = useSecureVerification({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// 验证成功后处理结果
|
||||||
|
if (result.success && result.data?.key) {
|
||||||
|
setKeyData(result.data.key);
|
||||||
|
setShowKeyModal(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
successMessage: t('密钥获取成功'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始查看密钥流程
|
||||||
|
const handleViewKey = async () => {
|
||||||
|
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||||
|
|
||||||
|
await startVerification(apiCall, {
|
||||||
|
title: t('查看渠道密钥'),
|
||||||
|
description: t('为了保护账户安全,请验证您的身份。'),
|
||||||
|
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 查看密钥按钮 */}
|
||||||
|
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
||||||
|
{t('查看密钥')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 安全验证模态框 */}
|
||||||
|
<SecureVerificationModal
|
||||||
|
visible={isModalVisible}
|
||||||
|
verificationMethods={verificationMethods}
|
||||||
|
verificationState={verificationState}
|
||||||
|
onVerify={executeVerification}
|
||||||
|
onCancel={cancelVerification}
|
||||||
|
onCodeChange={setVerificationCode}
|
||||||
|
onMethodSwitch={switchVerificationMethod}
|
||||||
|
title={verificationState.title}
|
||||||
|
description={verificationState.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 密钥显示模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={t('渠道密钥信息')}
|
||||||
|
visible={showKeyModal}
|
||||||
|
onCancel={() => setShowKeyModal(false)}
|
||||||
|
footer={
|
||||||
|
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||||
|
{t('完成')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
width={700}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<ChannelKeyDisplay
|
||||||
|
keyData={keyData}
|
||||||
|
showSuccessIcon={true}
|
||||||
|
successText={t('密钥获取成功')}
|
||||||
|
showWarning={true}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelKeyViewExample;
|
||||||
148
web/classic/src/components/common/modals/TwoFactorAuthModal.jsx
vendored
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可复用的两步验证模态框组件
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.visible - 是否显示模态框
|
||||||
|
* @param {string} props.code - 验证码值
|
||||||
|
* @param {boolean} props.loading - 是否正在验证
|
||||||
|
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||||
|
* @param {Function} props.onVerify - 验证回调
|
||||||
|
* @param {Function} props.onCancel - 取消回调
|
||||||
|
* @param {string} props.title - 模态框标题
|
||||||
|
* @param {string} props.description - 验证描述文本
|
||||||
|
* @param {string} props.placeholder - 输入框占位文本
|
||||||
|
*/
|
||||||
|
const TwoFactorAuthModal = ({
|
||||||
|
visible,
|
||||||
|
code,
|
||||||
|
loading,
|
||||||
|
onCodeChange,
|
||||||
|
onVerify,
|
||||||
|
onCancel,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
placeholder,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && code && !loading) {
|
||||||
|
onVerify();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||||
|
<svg
|
||||||
|
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{title || t('安全验证')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
loading={loading}
|
||||||
|
disabled={!code || loading}
|
||||||
|
onClick={onVerify}
|
||||||
|
>
|
||||||
|
{t('验证')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
width={500}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 安全提示 */}
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||||
|
<div className='flex items-start'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
className='text-blue-800 dark:text-blue-200'
|
||||||
|
>
|
||||||
|
{t('安全验证')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||||
|
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 验证码输入 */}
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong className='block mb-2'>
|
||||||
|
{t('验证身份')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
||||||
|
value={code}
|
||||||
|
onChange={onCodeChange}
|
||||||
|
size='large'
|
||||||
|
maxLength={8}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||||
|
{t(
|
||||||
|
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||||
|
)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFactorAuthModal;
|
||||||