Compare commits

..

No commits in common. "v1.0.0-rc.2" and "main" have entirely different histories.

1317 changed files with 1173 additions and 169900 deletions

View File

@ -1,83 +0,0 @@
---
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

View File

@ -1,254 +0,0 @@
---
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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
name: Publish Docker image (Multi-arch)
name: Publish Docker image (Multi Registries, native amd64+arm64)
on:
push:
@ -14,7 +14,7 @@ on:
jobs:
build_single_arch:
name: Build & push (${{ matrix.arch }})
name: Build & push (${{ matrix.arch }}) [native]
strategy:
fail-fast: false
matrix:
@ -26,8 +26,6 @@ jobs:
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
outputs:
tag: ${{ steps.version.outputs.tag }}
permissions:
packages: write
@ -36,46 +34,58 @@ jobs:
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Resolve tag & write VERSION
id: version
run: |
if [ -n "${{ github.event.inputs.tag }}" ]; then
TAG="${{ github.event.inputs.tag }}"
# Verify tag exists
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
echo "::error::Tag '$TAG' does not exist"
echo "Error: Tag '$TAG' does not exist in the repository"
exit 1
fi
else
TAG=${GITHUB_REF#refs/tags/}
fi
echo "TAG=${TAG}" >> $GITHUB_ENV
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "${TAG}" > VERSION
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
echo "TAG=$TAG" >> $GITHUB_ENV
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
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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)
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: calciumion/new-api
images: |
calciumion/new-api
# ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push
- name: Build & push single-arch (to both registries)
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: ${{ matrix.platform }}
@ -83,6 +93,8 @@ jobs:
tags: |
calciumion/new-api:${{ env.TAG }}-${{ 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 }}
cache-from: type=gha
cache-to: type=gha,mode=max
@ -90,52 +102,81 @@ jobs:
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@v3
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
- name: Sign image with cosign
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
- name: Image summary
- name: Output digest
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
create_manifests:
name: Create multi-arch manifests
name: Create multi-arch manifests (Docker Hub)
needs: [build_single_arch]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Set version
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
- name: Extract tag
run: |
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
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & push manifest (version)
- name: Create & push manifest (Docker Hub - version)
run: |
docker buildx imagetools create \
-t calciumion/new-api:${TAG} \
calciumion/new-api:${TAG}-amd64 \
calciumion/new-api:${TAG}-arm64
- name: Create & push manifest (latest)
- name: Create & push manifest (Docker Hub - latest)
run: |
docker buildx imagetools create \
-t calciumion/new-api:latest \
calciumion/new-api:latest-amd64 \
calciumion/new-api:latest-arm64
- name: Manifest summary
- name: Output manifest digest
run: |
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $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

View File

@ -29,22 +29,14 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
run: |
cd web/default
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
@ -86,23 +78,15 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web/default
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
@ -142,22 +126,14 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
run: |
cd web/default
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web/classic
bun install
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:

5
.gitignore vendored
View File

@ -8,9 +8,6 @@ upload
build
*.db-journal
logs
web/default/dist
web/classic/dist
web/node_modules
web/dist
.env
one-api
@ -22,9 +19,9 @@ tiktoken_cache
.gocache
.gomodcache/
.cache
web/bun.lock
plans
.claude
.cursor
electron/node_modules
electron/dist

View File

@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
@ -33,10 +33,8 @@ types/ — Type definitions (relay formats, file sources, errors)
i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container
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)
web/ — React frontend
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
```
## Internationalization (i18n)
@ -45,12 +43,13 @@ web/ — Frontend themes container
- Library: `nicksnyder/go-i18n/v2`
- Languages: en, zh
### Frontend (`web/default/src/i18n/`)
### Frontend (`web/src/i18n/`)
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
- Languages: en (base), zh (fallback), fr, ru, ja, vi
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
- Usage: `useTranslation()` hook, call `t('English key')` in components
- CLI tools: `bun run i18n:sync` (from `web/default/`)
- Languages: zh (fallback), en, fr, ru, ja, vi
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
- Usage: `useTranslation()` hook, call `t('中文key')` in components
- Semi UI locale synced via `SemiLocaleWrapper`
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
## Rules
@ -94,7 +93,7 @@ All database code MUST be fully compatible with all three databases simultaneous
### Rule 3: Frontend — Prefer Bun
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
- `bun install` for dependency installation
- `bun run dev` for development server
- `bun run build` for production build

View File

@ -7,7 +7,7 @@ This is an AI API gateway/proxy built with Go. It aggregates 40+ upstream AI pro
## Tech Stack
- **Backend**: Go 1.22+, Gin web framework, GORM v2 ORM
- **Frontend**: React 19, TypeScript, Rsbuild, Radix UI, Tailwind CSS
- **Frontend**: React 18, Vite, Semi Design UI (@douyinfe/semi-ui)
- **Databases**: SQLite, MySQL, PostgreSQL (all three must be supported)
- **Cache**: Redis (go-redis) + in-memory cache
- **Auth**: JWT, WebAuthn/Passkeys, OAuth (GitHub, Discord, OIDC, etc.)
@ -33,10 +33,8 @@ types/ — Type definitions (relay formats, file sources, errors)
i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container
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)
web/ — React frontend
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
```
## Internationalization (i18n)
@ -45,12 +43,13 @@ web/ — Frontend themes container
- Library: `nicksnyder/go-i18n/v2`
- Languages: en, zh
### Frontend (`web/default/src/i18n/`)
### Frontend (`web/src/i18n/`)
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
- Languages: en (base), zh (fallback), fr, ru, ja, vi
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
- Usage: `useTranslation()` hook, call `t('English key')` in components
- CLI tools: `bun run i18n:sync` (from `web/default/`)
- Languages: zh (fallback), en, fr, ru, ja, vi
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are Chinese source strings
- Usage: `useTranslation()` hook, call `t('中文key')` in components
- Semi UI locale synced via `SemiLocaleWrapper`
- CLI tools: `bun run i18n:extract`, `bun run i18n:sync`, `bun run i18n:lint`
## Rules
@ -94,7 +93,7 @@ All database code MUST be fully compatible with all three databases simultaneous
### Rule 3: Frontend — Prefer Bun
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
Use `bun` as the preferred package manager and script runner for the frontend (`web/` directory):
- `bun install` for dependency installation
- `bun run dev` for development server
- `bun run build` for production build

View File

@ -1,23 +1,13 @@
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
WORKDIR /build
COPY web/default/package.json .
COPY web/default/bun.lock .
COPY web/package.json .
COPY web/bun.lock .
RUN bun install
COPY ./web/default .
COPY ./web .
COPY ./VERSION .
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
ENV GO111MODULE=on CGO_ENABLED=0
@ -32,8 +22,7 @@ ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/default/dist
COPY --from=builder-classic /build/dist ./web/classic/dist
COPY --from=builder /build/dist ./web/dist
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

View File

@ -1,35 +0,0 @@
# 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"]

View File

@ -1,459 +0,0 @@
<div align="center">
![new-api](/web/default/public/logo.png)
# 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) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](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">
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](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>

View File

@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API

View File

@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API

View File

@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API

View File

@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API

View File

@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API

View File

@ -5,7 +5,6 @@ import (
//"os"
//"strconv"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@ -18,24 +17,6 @@ var Footer = ""
var Logo = ""
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 ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens

View File

@ -41,29 +41,3 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
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}
}

View File

@ -1,223 +0,0 @@
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",
})
}

View File

@ -1,220 +0,0 @@
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
}

View File

@ -1,268 +0,0 @@
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)
}

View File

@ -61,7 +61,6 @@ func GetStatus(c *gin.Context) {
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"theme": system_setting.GetThemeSettings().Frontend,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,

View File

@ -1,228 +0,0 @@
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
}

View File

@ -198,14 +198,6 @@ func UpdateOption(c *gin.Context) {
})
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":
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {

View File

@ -1,313 +0,0 @@
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预扣费%stokens%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,分组倍率 %.2ftokens %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预扣费%stokens%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,分组倍率 %.2ftokens %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 预扣费准确(%stokens%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] + "..."
}

View File

@ -1,73 +0,0 @@
# 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

View File

@ -279,8 +279,8 @@ type Message struct {
Content any `json:"content"`
Name *string `json:"name,omitempty"`
Prefix *bool `json:"prefix,omitempty"`
ReasoningContent *string `json:"reasoning_content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent
@ -431,16 +431,6 @@ const (
//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 {
if m.Prefix == nil {
return false

29
main.go
View File

@ -34,18 +34,12 @@ import (
_ "net/http/pprof"
)
//go:embed web/default/dist
//go:embed web/dist
var buildFS embed.FS
//go:embed web/default/dist/index.html
//go:embed web/dist/index.html
var indexPage []byte
//go:embed web/classic/dist
var classicBuildFS embed.FS
//go:embed web/classic/dist/index.html
var classicIndexPage []byte
func main() {
startTime := time.Now()
@ -189,12 +183,7 @@ func main() {
InjectGoogleAnalytics()
// 设置路由
router.SetRouter(server, router.ThemeAssets{
DefaultBuildFS: buildFS,
DefaultIndexPage: indexPage,
ClassicBuildFS: classicBuildFS,
ClassicIndexPage: classicIndexPage,
})
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
@ -224,10 +213,8 @@ func InjectUmamiAnalytics() {
analyticsInjectBuilder.WriteString("\"></script>")
}
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
analyticsInject := []byte(analyticsInjectBuilder.String())
placeholder := []byte("<!--umami-->\n")
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
}
func InjectGoogleAnalytics() {
@ -248,10 +235,8 @@ func InjectGoogleAnalytics() {
analyticsInjectBuilder.WriteString("</script>")
}
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
analyticsInject := []byte(analyticsInjectBuilder.String())
placeholder := []byte("<!--Google Analytics-->\n")
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
}
func InitResources() error {

View File

@ -1,35 +1,14 @@
FRONTEND_DIR = ./web/default
FRONTEND_CLASSIC_DIR = ./web/classic
FRONTEND_DIR = ./web
BACKEND_DIR = .
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
.PHONY: all build-frontend start-backend
all: build-all-frontends start-backend
all: build-frontend start-backend
build-frontend:
@echo "Building default frontend..."
@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
@echo "Building frontend..."
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
start-backend:
@echo "Starting backend dev server..."
@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

View File

@ -581,8 +581,6 @@ func handleConfigUpdate(key, value string) bool {
} else if configName == "billing_setting" {
InvalidatePricingCache()
ratio_setting.InvalidateExposedDataCache()
} else if configName == "theme" {
system_setting.UpdateAndSyncTheme()
}
return true // 已处理

View File

@ -416,17 +416,6 @@ func (t *Task) UpdateWithStatus(fromStatus TaskStatus) (bool, error) {
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.
// 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

View File

@ -567,14 +567,12 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
}
choice.SetStringContent(responseText)
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
choice.ReasoningContent = responseThinking
}
if len(tools) > 0 {
choice.Message.SetToolCalls(tools)
}
if thinkingContent != "" {
choice.Message.ReasoningContent = &thinkingContent
}
choice.Message.ReasoningContent = thinkingContent
fullTextResponse.Model = claudeResponse.Model
choices = append(choices, choice)
fullTextResponse.Choices = choices

View File

@ -1097,7 +1097,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
toolCalls = append(toolCalls, *call)
}
} else if part.Thought {
choice.Message.ReasoningContent = &part.Text
choice.Message.ReasoningContent = part.Text
} else {
if part.ExecutableCode != nil {
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")

View File

@ -273,7 +273,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
if rc := reasoningBuilder.String(); rc != "" {
msg.ReasoningContent = &rc
msg.ReasoningContent = rc
}
full := dto.OpenAITextResponse{
Id: common.GetUUID(),

View File

@ -245,7 +245,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens := simpleResponse.Usage.CompletionTokens
if completionTokens == 0 {
for _, choice := range simpleResponse.Choices {
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
completionTokens += ctkm
}
}

View File

@ -95,7 +95,20 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
if strings.TrimSpace(region) == "" {
region = "global"
}
return vertexcore.BuildGoogleModelURL(a.baseURL, vertexcore.DefaultAPIVersion, adc.ProjectID, region, modelName, "predictLongRunning"), nil
if region == "global" {
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.
@ -225,22 +238,6 @@ func (a *TaskAdaptor) GetModelList() []string {
}
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
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
@ -251,9 +248,20 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy
if err != nil {
return nil, fmt.Errorf("decode task_id failed: %w", err)
}
url, err := buildFetchOperationURL(baseUrl, upstreamName)
if err != nil {
return nil, err
region := extractRegionFromOperationName(upstreamName)
if region == "" {
region = "us-central1"
}
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}
data, err := common.Marshal(payload)

View File

@ -134,11 +134,47 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
a.AccountCredentials = *adc
if a.RequestMode == RequestModeGemini {
return BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
if region == "global" {
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 {
return BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
if region == "global" {
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 {
return BuildOpenSourceChatCompletionsURL(info.ChannelBaseUrl, adc.ProjectID, region), nil
return fmt.Sprintf(
"https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
adc.ProjectID,
region,
), nil
}
} else {
var keyPrefix string
@ -147,17 +183,20 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
} else {
keyPrefix = "?"
}
if a.RequestMode == RequestModeGemini {
if region == "global" {
return fmt.Sprintf(
"%s%skey=%s",
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil
} else if a.RequestMode == RequestModeClaude {
} else {
return fmt.Sprintf(
"%s%skey=%s",
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
region,
modelName,
suffix,
keyPrefix,
info.ApiKey,
), nil

View File

@ -1,86 +0,0 @@
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),
)
}

View File

@ -1,6 +1,7 @@
package router
import (
"embed"
"fmt"
"net/http"
"os"
@ -12,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
)
func SetRouter(router *gin.Engine, assets ThemeAssets) {
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router)
@ -23,7 +24,7 @@ func SetRouter(router *gin.Engine, assets ThemeAssets) {
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
}
if frontendBaseUrl == "" {
SetWebRouter(router, assets)
SetWebRouter(router, buildFS, indexPage)
} else {
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
router.NoRoute(func(c *gin.Context) {

View File

@ -13,23 +13,11 @@ import (
"github.com/gin-gonic/gin"
)
// 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)
func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
router.Use(middleware.Cache())
router.Use(static.Serve("/", themeFS))
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web")
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
@ -37,10 +25,6 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
return
}
c.Header("Cache-Control", "no-cache")
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)
}
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
})
}

View File

@ -1,79 +0,0 @@
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
}

View File

@ -1,32 +0,0 @@
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()
}

View File

@ -1,113 +0,0 @@
/*
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;

View File

@ -1,148 +0,0 @@
/*
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;

View File

@ -1,40 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export { default as SettingsPanel } from './SettingsPanel';
export { default as ChatArea } from './ChatArea';
export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as SSEViewer } from './SSEViewer';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';
export { default as ConfigManager } from './ConfigManager';
export {
saveConfig,
loadConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';

View File

@ -1,280 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import {
Empty,
Skeleton,
Space,
Tag,
Collapsible,
Tabs,
TabPane,
Typography,
Avatar,
} from '@douyinfe/semi-ui';
import {
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react';
import { renderModelTag, getModelCategories } from '../../../../helpers';
const ModelsList = ({ t, models, modelsLoading, copyText }) => {
const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
// Initialize from localStorage if available
const savedState = localStorage.getItem('modelsExpanded');
return savedState ? JSON.parse(savedState) : false;
});
const [activeModelCategory, setActiveModelCategory] = useState('all');
const MODELS_DISPLAY_COUNT = 25; //
// Save models expanded state to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded));
}, [isModelsExpanded]);
return (
<div className='py-4'>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<Avatar size='small' color='green' className='mr-3 shadow-md'>
<Settings size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('可用模型')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('查看当前可用的所有模型')}
</div>
</div>
</div>
{/* 可用模型部分 */}
<div className='bg-gray-50 dark:bg-gray-800 rounded-xl'>
{modelsLoading ? (
// -
<div className='space-y-4'>
{/* 模拟分类标签 */}
<div
className='mb-4'
style={{ borderBottom: '1px solid var(--semi-color-border)' }}
>
<div className='flex overflow-x-auto py-2 gap-2'>
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton.Button
key={`cat-${index}`}
style={{
width: index === 0 ? 130 : 100 + Math.random() * 50,
height: 36,
borderRadius: 8,
}}
/>
))}
</div>
</div>
{/* 模拟模型标签列表 */}
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 20 }).map((_, index) => (
<Skeleton.Button
key={`model-${index}`}
style={{
width: 100 + Math.random() * 100,
height: 32,
borderRadius: 16,
margin: '4px',
}}
/>
))}
</div>
</div>
) : models.length === 0 ? (
<div className='py-8'>
<Empty
image={
<IllustrationNoContent style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 150, height: 150 }}
/>
}
description={t('没有可用模型')}
style={{ padding: '24px 0' }}
/>
</div>
) : (
<>
{/* 模型分类标签页 */}
<div className='mb-4'>
<Tabs
type='card'
activeKey={activeModelCategory}
onChange={(key) => setActiveModelCategory(key)}
className='mt-2'
collapsible
>
{Object.entries(getModelCategories(t)).map(
([key, category]) => {
//
const modelCount =
key === 'all'
? models.length
: models.filter((model) =>
category.filter({ model_name: model }),
).length;
if (modelCount === 0 && key !== 'all') return null;
return (
<TabPane
tab={
<span className='flex items-center gap-2'>
{category.icon && (
<span className='w-4 h-4'>{category.icon}</span>
)}
{category.label}
<Tag
color={
activeModelCategory === key ? 'red' : 'grey'
}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
},
)}
</Tabs>
</div>
<div className='bg-white dark:bg-gray-700 rounded-lg p-3'>
{(() => {
//
const categories = getModelCategories(t);
const filteredModels =
activeModelCategory === 'all'
? models
: models.filter((model) =>
categories[activeModelCategory].filter({
model_name: model,
}),
);
//
if (filteredModels.length === 0) {
return (
<Empty
image={
<IllustrationNoContent
style={{ width: 120, height: 120 }}
/>
}
darkModeImage={
<IllustrationNoContentDark
style={{ width: 120, height: 120 }}
/>
}
description={t('该分类下没有可用模型')}
style={{ padding: '16px 0' }}
/>
);
}
if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
return (
<Space wrap>
{filteredModels.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
}),
)}
</Space>
);
} else {
return (
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{filteredModels.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
}),
)}
<Tag
color='grey'
type='light'
className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />}
>
{t('收起')}
</Tag>
</Space>
</Collapsible>
{!isModelsExpanded && (
<Space wrap>
{filteredModels
.slice(0, MODELS_DISPLAY_COUNT)
.map((model) =>
renderModelTag(model, {
size: 'small',
shape: 'circle',
onClick: () => copyText(model),
}),
)}
<Tag
color='grey'
type='light'
className='cursor-pointer !rounded-lg'
onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />}
>
{t('更多')}{' '}
{filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
{t('个模型')}
</Tag>
</Space>
)}
</>
);
}
})()}
</div>
</>
)}
</div>
</div>
);
};
export default ModelsList;

View File

@ -1,44 +0,0 @@
/*
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 { Typography } from '@douyinfe/semi-ui';
import { Layers } from 'lucide-react';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
const { Text } = Typography;
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
return (
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
<div className='flex items-center text-green-500'>
<Layers size={16} className='mr-2' />
<Text>{t('模型管理')}</Text>
</div>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</div>
);
};
export default ModelsDescription;

View File

@ -1,312 +0,0 @@
/*
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 { useState, useCallback } from 'react';
import { API } from '../../helpers';
import { showError } from '../../helpers';
export const useDeploymentResources = () => {
const [hardwareTypes, setHardwareTypes] = useState([]);
const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(0);
const [locations, setLocations] = useState([]);
const [locationsTotalAvailable, setLocationsTotalAvailable] = useState(0);
const [availableReplicas, setAvailableReplicas] = useState([]);
const [priceEstimation, setPriceEstimation] = useState(null);
const [loadingHardware, setLoadingHardware] = useState(false);
const [loadingLocations, setLoadingLocations] = useState(false);
const [loadingReplicas, setLoadingReplicas] = useState(false);
const [loadingPrice, setLoadingPrice] = useState(false);
const fetchHardwareTypes = useCallback(async () => {
try {
setLoadingHardware(true);
const response = await API.get('/api/deployments/hardware-types');
if (response.data.success) {
const { hardware_types: hardwareList = [], total_available } =
response.data.data || {};
const normalizedHardware = hardwareList.map((hardware) => {
const availableCountValue = Number(hardware.available_count);
const availableCount = Number.isNaN(availableCountValue)
? 0
: availableCountValue;
const availableBool =
typeof hardware.available === 'boolean'
? hardware.available
: availableCount > 0;
return {
...hardware,
available: availableBool,
available_count: availableCount,
};
});
const providedTotal = Number(total_available);
const fallbackTotal = normalizedHardware.reduce(
(acc, item) =>
acc +
(Number.isNaN(item.available_count) ? 0 : item.available_count),
0,
);
const hasProvidedTotal =
total_available !== undefined &&
total_available !== null &&
total_available !== '' &&
!Number.isNaN(providedTotal);
setHardwareTypes(normalizedHardware);
setHardwareTotalAvailable(
hasProvidedTotal ? providedTotal : fallbackTotal,
);
return normalizedHardware;
} else {
showError('获取硬件类型失败: ' + response.data.message);
setHardwareTotalAvailable(0);
return [];
}
} catch (error) {
showError('获取硬件类型失败: ' + error.message);
setHardwareTotalAvailable(0);
return [];
} finally {
setLoadingHardware(false);
}
}, []);
const fetchLocations = useCallback(async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setLocations([]);
setLocationsTotalAvailable(0);
return [];
}
try {
setLoadingLocations(true);
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
);
if (response.data.success) {
const replicas = response.data.data?.replicas || [];
const nextLocationsMap = new Map();
replicas.forEach((replica) => {
const rawId = replica?.location_id ?? replica?.location?.id;
if (rawId === null || rawId === undefined) return;
const mapKey = String(rawId);
if (nextLocationsMap.has(mapKey)) return;
const rawIso2 =
replica?.iso2 ?? replica?.location_iso2 ?? replica?.location?.iso2;
const iso2 = rawIso2 ? String(rawIso2).toUpperCase() : '';
const name =
replica?.location_name ??
replica?.location?.name ??
replica?.name ??
String(rawId);
nextLocationsMap.set(mapKey, {
id: rawId,
name: String(name),
iso2,
region:
replica?.region ??
replica?.location_region ??
replica?.location?.region,
country:
replica?.country ??
replica?.location_country ??
replica?.location?.country,
code:
replica?.code ??
replica?.location_code ??
replica?.location?.code,
available: Number(replica?.available_count) || 0,
});
});
const normalizedLocations = Array.from(nextLocationsMap.values());
setLocations(normalizedLocations);
setLocationsTotalAvailable(
normalizedLocations.reduce(
(acc, item) => acc + (item.available || 0),
0,
),
);
return normalizedLocations;
} else {
showError('获取部署位置失败: ' + response.data.message);
setLocationsTotalAvailable(0);
return [];
}
} catch (error) {
showError('获取部署位置失败: ' + error.message);
setLocationsTotalAvailable(0);
return [];
} finally {
setLoadingLocations(false);
}
}, []);
const fetchAvailableReplicas = useCallback(
async (hardwareId, gpuCount = 1) => {
if (!hardwareId) {
setAvailableReplicas([]);
return [];
}
try {
setLoadingReplicas(true);
const response = await API.get(
`/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
);
if (response.data.success) {
const replicas = response.data.data.replicas || [];
setAvailableReplicas(replicas);
return replicas;
} else {
showError('获取可用资源失败: ' + response.data.message);
setAvailableReplicas([]);
return [];
}
} catch (error) {
console.error('Load available replicas error:', error);
setAvailableReplicas([]);
return [];
} finally {
setLoadingReplicas(false);
}
},
[],
);
const calculatePrice = useCallback(async (params) => {
const {
locationIds,
hardwareId,
gpusPerContainer,
durationHours,
replicaCount,
} = params;
if (
!locationIds?.length ||
!hardwareId ||
!gpusPerContainer ||
!durationHours ||
!replicaCount
) {
setPriceEstimation(null);
return null;
}
try {
setLoadingPrice(true);
const requestData = {
location_ids: locationIds,
hardware_id: hardwareId,
gpus_per_container: gpusPerContainer,
duration_hours: durationHours,
replica_count: replicaCount,
};
const response = await API.post(
'/api/deployments/price-estimation',
requestData,
);
if (response.data.success) {
const estimation = response.data.data;
setPriceEstimation(estimation);
return estimation;
} else {
showError('价格计算失败: ' + response.data.message);
setPriceEstimation(null);
return null;
}
} catch (error) {
console.error('Price calculation error:', error);
setPriceEstimation(null);
return null;
} finally {
setLoadingPrice(false);
}
}, []);
const checkClusterNameAvailability = useCallback(async (name) => {
if (!name?.trim()) return false;
try {
const response = await API.get(
`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`,
);
if (response.data.success) {
return response.data.data.available;
} else {
showError('检查名称可用性失败: ' + response.data.message);
return false;
}
} catch (error) {
console.error('Check cluster name availability error:', error);
return false;
}
}, []);
const createDeployment = useCallback(async (deploymentData) => {
try {
const response = await API.post('/api/deployments', deploymentData);
if (response.data.success) {
return response.data.data;
} else {
throw new Error(response.data.message || '创建部署失败');
}
} catch (error) {
throw error;
}
}, []);
return {
// Data
hardwareTypes,
hardwareTotalAvailable,
locations,
locationsTotalAvailable,
availableReplicas,
priceEstimation,
// Loading states
loadingHardware,
loadingLocations,
loadingReplicas,
loadingPrice,
// Functions
fetchHardwareTypes,
fetchLocations,
fetchAvailableReplicas,
calculatePrice,
checkClusterNameAvailability,
createDeployment,
// Clear functions
clearPriceEstimation: () => setPriceEstimation(null),
clearAvailableReplicas: () => setAvailableReplicas([]),
};
};
export default useDeploymentResources;

View File

@ -1,286 +0,0 @@
/*
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 { useState } from 'react';
import { API, showError, showSuccess } from '../../helpers';
export const useEnhancedDeploymentActions = (t) => {
const [loading, setLoading] = useState({});
// Set loading state for specific operation
const setOperationLoading = (operation, deploymentId, isLoading) => {
setLoading((prev) => ({
...prev,
[`${operation}_${deploymentId}`]: isLoading,
}));
};
// Get loading state for specific operation
const isOperationLoading = (operation, deploymentId) => {
return loading[`${operation}_${deploymentId}`] || false;
};
// Extend deployment duration
const extendDeployment = async (deploymentId, durationHours) => {
try {
setOperationLoading('extend', deploymentId, true);
const response = await API.post(
`/api/deployments/${deploymentId}/extend`,
{
duration_hours: durationHours,
},
);
if (response.data.success) {
showSuccess(t('容器时长延长成功'));
return response.data.data;
}
} catch (error) {
showError(
t('延长时长失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('extend', deploymentId, false);
}
};
// Get deployment details
const getDeploymentDetails = async (deploymentId) => {
try {
setOperationLoading('details', deploymentId, true);
const response = await API.get(`/api/deployments/${deploymentId}`);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(
t('获取详情失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('details', deploymentId, false);
}
};
// Get deployment logs
const getDeploymentLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('logs', deploymentId, true);
const params = new URLSearchParams();
if (options.containerId)
params.append('container_id', options.containerId);
if (options.level) params.append('level', options.level);
if (options.limit) params.append('limit', options.limit.toString());
if (options.cursor) params.append('cursor', options.cursor);
if (options.follow) params.append('follow', 'true');
if (options.startTime) params.append('start_time', options.startTime);
if (options.endTime) params.append('end_time', options.endTime);
const response = await API.get(
`/api/deployments/${deploymentId}/logs?${params}`,
);
if (response.data.success) {
return response.data.data;
}
} catch (error) {
showError(
t('获取日志失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('logs', deploymentId, false);
}
};
// Update deployment configuration
const updateDeploymentConfig = async (deploymentId, config) => {
try {
setOperationLoading('config', deploymentId, true);
const response = await API.put(
`/api/deployments/${deploymentId}`,
config,
);
if (response.data.success) {
showSuccess(t('容器配置更新成功'));
return response.data.data;
}
} catch (error) {
showError(
t('更新配置失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('config', deploymentId, false);
}
};
// Delete (destroy) deployment
const deleteDeployment = async (deploymentId) => {
try {
setOperationLoading('delete', deploymentId, true);
const response = await API.delete(`/api/deployments/${deploymentId}`);
if (response.data.success) {
showSuccess(t('容器销毁请求已提交'));
return response.data.data;
}
} catch (error) {
showError(
t('销毁容器失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('delete', deploymentId, false);
}
};
// Update deployment name
const updateDeploymentName = async (deploymentId, newName) => {
try {
setOperationLoading('rename', deploymentId, true);
const response = await API.put(`/api/deployments/${deploymentId}/name`, {
name: newName,
});
if (response.data.success) {
showSuccess(t('容器名称更新成功'));
return response.data.data;
}
} catch (error) {
showError(
t('更新名称失败') +
': ' +
(error.response?.data?.message || error.message),
);
throw error;
} finally {
setOperationLoading('rename', deploymentId, false);
}
};
// Batch operations
const batchDelete = async (deploymentIds) => {
try {
setOperationLoading('batch_delete', 'all', true);
const results = await Promise.allSettled(
deploymentIds.map((id) => deleteDeployment(id)),
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
if (successful > 0) {
showSuccess(
t('批量操作完成: {{success}}个成功, {{failed}}个失败', {
success: successful,
failed: failed,
}),
);
}
return { successful, failed };
} catch (error) {
showError(t('批量操作失败') + ': ' + error.message);
throw error;
} finally {
setOperationLoading('batch_delete', 'all', false);
}
};
// Export logs
const exportLogs = async (deploymentId, options = {}) => {
try {
setOperationLoading('export_logs', deploymentId, true);
const logs = await getDeploymentLogs(deploymentId, {
...options,
limit: 10000, // Get more logs for export
});
if (logs && logs.logs) {
const logText = logs.logs
.map(
(log) =>
`[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`,
)
.join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `deployment-${deploymentId}-logs-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('日志导出成功'));
}
} catch (error) {
showError(t('导出日志失败') + ': ' + error.message);
throw error;
} finally {
setOperationLoading('export_logs', deploymentId, false);
}
};
return {
// Actions
extendDeployment,
getDeploymentDetails,
getDeploymentLogs,
updateDeploymentConfig,
deleteDeployment,
updateDeploymentName,
batchDelete,
exportLogs,
// Loading states
isOperationLoading,
loading,
// Utility
setOperationLoading,
};
};
export default useEnhancedDeploymentActions;

File diff suppressed because it is too large Load Diff

View File

@ -1,482 +0,0 @@
/*
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 { useState, useEffect, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import {
Card,
Button,
Switch,
Typography,
Row,
Col,
Avatar,
} from '@douyinfe/semi-ui';
import { API, showSuccess, showError } from '../../../helpers';
import { StatusContext } from '../../../context/Status';
import { UserContext } from '../../../context/User';
import { useUserPermissions } from '../../../hooks/common/useUserPermissions';
import { mergeAdminConfig, useSidebar } from '../../../hooks/common/useSidebar';
import { Settings } from 'lucide-react';
const { Text } = Typography;
export default function SettingsSidebarModulesUser() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [statusState] = useContext(StatusContext);
// 使
const {
permissions,
loading: permissionsLoading,
hasSidebarSettingsPermission,
isSidebarSectionAllowed,
isSidebarModuleAllowed,
} = useUserPermissions();
// 使useSidebar
const { refreshUserConfig } = useSidebar();
//
if (!permissionsLoading && !hasSidebarSettingsPermission()) {
return null;
}
//
if (permissionsLoading) {
return null;
}
//
const generateDefaultConfig = () => {
const defaultConfig = {};
// - 访
if (isSidebarSectionAllowed('chat')) {
defaultConfig.chat = {
enabled: true,
playground: isSidebarModuleAllowed('chat', 'playground'),
chat: isSidebarModuleAllowed('chat', 'chat'),
};
}
// - 访
if (isSidebarSectionAllowed('console')) {
defaultConfig.console = {
enabled: true,
detail: isSidebarModuleAllowed('console', 'detail'),
token: isSidebarModuleAllowed('console', 'token'),
log: isSidebarModuleAllowed('console', 'log'),
midjourney: isSidebarModuleAllowed('console', 'midjourney'),
task: isSidebarModuleAllowed('console', 'task'),
};
}
// - 访
if (isSidebarSectionAllowed('personal')) {
defaultConfig.personal = {
enabled: true,
topup: isSidebarModuleAllowed('personal', 'topup'),
personal: isSidebarModuleAllowed('personal', 'personal'),
};
}
// - 访
if (isSidebarSectionAllowed('admin')) {
defaultConfig.admin = {
enabled: true,
channel: isSidebarModuleAllowed('admin', 'channel'),
models: isSidebarModuleAllowed('admin', 'models'),
deployment: isSidebarModuleAllowed('admin', 'deployment'),
redemption: isSidebarModuleAllowed('admin', 'redemption'),
user: isSidebarModuleAllowed('admin', 'user'),
setting: isSidebarModuleAllowed('admin', 'setting'),
};
}
return defaultConfig;
};
//
const [sidebarModulesUser, setSidebarModulesUser] = useState({});
//
const [adminConfig, setAdminConfig] = useState(null);
//
function handleSectionChange(sectionKey) {
return (checked) => {
const newModules = {
...sidebarModulesUser,
[sectionKey]: {
...sidebarModulesUser[sectionKey],
enabled: checked,
},
};
setSidebarModulesUser(newModules);
console.log('用户边栏区域配置变更:', sectionKey, checked, newModules);
};
}
//
function handleModuleChange(sectionKey, moduleKey) {
return (checked) => {
const newModules = {
...sidebarModulesUser,
[sectionKey]: {
...sidebarModulesUser[sectionKey],
[moduleKey]: checked,
},
};
setSidebarModulesUser(newModules);
console.log(
'用户边栏功能配置变更:',
sectionKey,
moduleKey,
checked,
newModules,
);
};
}
//
function resetSidebarModules() {
const defaultConfig = generateDefaultConfig();
setSidebarModulesUser(defaultConfig);
showSuccess(t('已重置为默认配置'));
console.log('用户边栏配置重置为默认:', defaultConfig);
}
//
async function onSubmit() {
setLoading(true);
try {
console.log('保存用户边栏配置:', sidebarModulesUser);
const res = await API.put('/api/user/self', {
sidebar_modules: JSON.stringify(sidebarModulesUser),
});
const { success, message } = res.data;
if (success) {
showSuccess(t('保存成功'));
console.log('用户边栏配置保存成功');
// useSidebar
await refreshUserConfig();
console.log('用户边栏配置已刷新,边栏将立即更新');
} else {
showError(message);
console.error('用户边栏配置保存失败:', message);
}
} catch (error) {
showError(t('保存失败,请重试'));
console.error('用户边栏配置保存异常:', error);
} finally {
setLoading(false);
}
}
//
useEffect(() => {
const loadConfigs = async () => {
try {
//
if (statusState?.status?.SidebarModulesAdmin) {
try {
const adminConf = JSON.parse(
statusState.status.SidebarModulesAdmin,
);
const mergedAdminConf = mergeAdminConfig(adminConf);
setAdminConfig(mergedAdminConf);
console.log('加载管理员边栏配置:', mergedAdminConf);
} catch (error) {
const mergedAdminConf = mergeAdminConfig(null);
setAdminConfig(mergedAdminConf);
console.log(
'加载管理员边栏配置失败,使用默认配置:',
mergedAdminConf,
);
}
} else {
const mergedAdminConf = mergeAdminConfig(null);
setAdminConfig(mergedAdminConf);
console.log('管理员边栏配置缺失,使用默认配置:', mergedAdminConf);
}
//
const userRes = await API.get('/api/user/self');
if (userRes.data.success && userRes.data.data.sidebar_modules) {
let userConf;
// sidebar_modules
if (typeof userRes.data.data.sidebar_modules === 'string') {
userConf = JSON.parse(userRes.data.data.sidebar_modules);
} else {
userConf = userRes.data.data.sidebar_modules;
}
console.log('从API加载的用户配置:', userConf);
//
const filteredUserConf = {};
Object.keys(userConf).forEach((sectionKey) => {
if (isSidebarSectionAllowed(sectionKey)) {
filteredUserConf[sectionKey] = { ...userConf[sectionKey] };
//
Object.keys(userConf[sectionKey]).forEach((moduleKey) => {
if (
moduleKey !== 'enabled' &&
!isSidebarModuleAllowed(sectionKey, moduleKey)
) {
delete filteredUserConf[sectionKey][moduleKey];
}
});
}
});
setSidebarModulesUser(filteredUserConf);
console.log('权限过滤后的用户配置:', filteredUserConf);
} else {
// 使
const defaultConfig = generateDefaultConfig();
setSidebarModulesUser(defaultConfig);
console.log('用户无配置,使用默认配置:', defaultConfig);
}
} catch (error) {
console.error('加载边栏配置失败:', error);
// 使
const defaultConfig = generateDefaultConfig();
setSidebarModulesUser(defaultConfig);
}
};
//
if (!permissionsLoading && hasSidebarSettingsPermission()) {
loadConfigs();
}
}, [
statusState,
permissionsLoading,
hasSidebarSettingsPermission,
isSidebarSectionAllowed,
isSidebarModuleAllowed,
]);
//
const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
if (!adminConfig) return true;
if (moduleKey) {
return (
adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]
);
} else {
return adminConfig[sectionKey]?.enabled;
}
};
//
const sectionConfigs = [
{
key: 'chat',
title: t('聊天区域'),
description: t('操练场和聊天功能'),
modules: [
{
key: 'playground',
title: t('操练场'),
description: t('AI模型测试环境'),
},
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
],
},
{
key: 'console',
title: t('控制台区域'),
description: t('数据管理和日志查看'),
modules: [
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
{
key: 'midjourney',
title: t('绘图日志'),
description: t('绘图任务记录'),
},
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
],
},
{
key: 'personal',
title: t('个人中心区域'),
description: t('用户个人功能'),
modules: [
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
{
key: 'personal',
title: t('个人设置'),
description: t('个人信息设置'),
},
],
},
{
key: 'admin',
title: t('管理员区域'),
description: t('系统管理功能'),
modules: [
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
{
key: 'deployment',
title: t('模型部署'),
description: t('模型部署管理'),
},
{
key: 'redemption',
title: t('兑换码管理'),
description: t('兑换码生成管理'),
},
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
{
key: 'setting',
title: t('系统设置'),
description: t('系统参数配置'),
},
],
},
]
.filter((section) => {
// 使
return isSidebarSectionAllowed(section.key);
})
.map((section) => ({
...section,
modules: section.modules.filter((module) =>
isSidebarModuleAllowed(section.key, module.key),
),
}))
.filter(
(section) =>
//
section.modules.length > 0 && isAllowedByAdmin(section.key),
);
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<Avatar size='small' color='purple' className='mr-3 shadow-md'>
<Settings size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('左侧边栏个人设置')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('个性化设置左侧边栏的显示内容')}
</div>
</div>
</div>
<div className='mb-4'>
<Text type='secondary' className='text-sm text-gray-600'>
{t('您可以个性化设置侧边栏的要显示功能')}
</Text>
</div>
{sectionConfigs.map((section) => (
<div key={section.key} className='mb-6'>
{/* 区域标题和总开关 */}
<div className='flex justify-between items-center mb-4 p-4 bg-gray-50 rounded-xl border border-gray-200'>
<div>
<div className='font-semibold text-base text-gray-900 mb-1'>
{section.title}
</div>
<Text className='text-xs text-gray-600'>
{section.description}
</Text>
</div>
<Switch
checked={sidebarModulesUser[section.key]?.enabled !== false}
onChange={handleSectionChange(section.key)}
size='default'
/>
</div>
{/* 功能模块网格 */}
<Row gutter={[12, 12]}>
{section.modules.map((module) => (
<Col key={module.key} xs={24} sm={12} md={8} lg={6} xl={6}>
<Card
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
sidebarModulesUser[section.key]?.enabled !== false
? ''
: 'opacity-50'
}`}
bodyStyle={{ padding: '16px' }}
hoverable
>
<div className='flex justify-between items-center h-full'>
<div className='flex-1 text-left'>
<div className='font-semibold text-sm text-gray-900 mb-1'>
{module.title}
</div>
<Text className='text-xs text-gray-600 leading-relaxed block'>
{module.description}
</Text>
</div>
<div className='ml-4'>
<Switch
checked={
sidebarModulesUser[section.key]?.[module.key] !==
false
}
onChange={handleModuleChange(section.key, module.key)}
size='default'
disabled={
sidebarModulesUser[section.key]?.enabled === false
}
/>
</div>
</div>
</Card>
</Col>
))}
</Row>
</div>
))}
{/* 底部按钮 */}
<div className='flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200'>
<Button
type='tertiary'
onClick={resetSidebarModules}
className='!rounded-lg'
>
{t('重置为默认')}
</Button>
<Button
type='primary'
onClick={onSubmit}
loading={loading}
className='!rounded-lg'
>
{t('保存设置')}
</Button>
</div>
</Card>
);
}

View File

@ -1,27 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tanstack/*

View File

@ -1 +0,0 @@
24.10.0

1
web/default/.npmrc vendored
View File

@ -1 +0,0 @@
registry=https://registry.npmjs.org/

View File

@ -1,18 +0,0 @@
# Ignore everything
/*
# Except these files & folders
!/src
!index.html
!package.json
!tailwind.config.js
!tsconfig.json
!tsconfig.node.json
!vite.config.ts
!.prettierrc
!README.md
!eslint.config.js
!postcss.config.js
# Ignore auto generated routeTree.gen.ts
/src/routeTree.gen.ts

View File

@ -1,49 +0,0 @@
{
"arrowParens": "always",
"semi": false,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf",
"plugins": [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"importOrder": [
"^path$",
"^vite$",
"^@vitejs/(.*)$",
"^react$",
"^react-dom/client$",
"^react/(.*)$",
"^globals$",
"^zod$",
"^axios$",
"^dayjs$",
"^react-hook-form$",
"^use-intl$",
"^@radix-ui/(.*)$",
"^@hookform/resolvers/zod$",
"^@tanstack/react-query$",
"^@tanstack/react-router$",
"^@tanstack/react-table$",
"<THIRD_PARTY_MODULES>",
"^@/assets/(.*)",
"^@/api/(.*)$",
"^@/stores/(.*)$",
"^@/lib/(.*)$",
"^@/utils/(.*)$",
"^@/constants/(.*)$",
"^@/context/(.*)$",
"^@/hooks/(.*)$",
"^@/components/layouts/(.*)$",
"^@/components/ui/(.*)$",
"^@/components/errors/(.*)$",
"^@/components/(.*)$",
"^@/features/(.*)$",
"^[./]"
]
}

175
web/default/AGENTS.md vendored
View File

@ -1,175 +0,0 @@
# 前端开发规范
本文档定义前端项目的开发规范与最佳实践,供开发与 AI 助手共同遵循。具体依赖与脚本以 `package.json` 为准。
---
## 一、项目概览
### 技术栈
| 类别 | 技术 |
|----------|------|
| 包管理 | Bun |
| 框架 | React 19、TypeScript |
| 数据与请求 | @tanstack/react-query、axios、Zustand |
| 路由 | @tanstack/react-router |
| 表格与列表 | @tanstack/react-table、@tanstack/react-virtual |
| 国际化 | i18next、react-i18next、i18next-browser-languagedetector |
| 日期 | Day.js |
| UI 与样式 | Radix UI、Lucide React、Tailwind CSS、clsx / class-variance-authority |
| 表单 | React Hook Form、Zod |
| 图表 | @visactor/vchart@visactor/react-vchart |
| 工具 | qrcode.react、prettier、eslint、vitest可选|
优先选用成熟、维护良好的开源库;仅在现有库无法满足或需特殊适配时自行实现,并评估可维护性与通用性。
---
## 二、目录
- [一、项目概览](#一项目概览)
- [二、目录](#二目录)
- [三、开发规范](#三开发规范)
- [3.1 国际化](#31-国际化)
- [3.2 代码风格与类型](#32-代码风格与类型)
- [3.3 组件](#33-组件)
- [3.4 性能](#34-性能)
- [3.5 状态管理](#35-状态管理)
- [3.6 API 请求](#36-api-请求)
- [3.7 表单](#37-表单)
- [3.8 路由](#38-路由)
- [3.9 错误处理](#39-错误处理)
- [3.10 样式](#310-样式)
- [3.11 文件组织](#311-文件组织)
- [3.12 可访问性](#312-可访问性)
- [3.13 安全](#313-安全)
- [3.14 测试](#314-测试)
- [3.15 依赖管理](#315-依赖管理)
- [3.16 构建与部署](#316-构建与部署)
- [四、协作与提交](#四协作与提交)
- [更新日志](#更新日志)
---
## 三、开发规范
### 3.1 国际化
- **页面文本**:所有面向用户的文案均需支持 i18n使用 `useTranslation()``t()` 进行翻译。
- **使用场景**
- **React 组件**:必须使用 `const { t } = useTranslation()`,以保证语言切换时组件会重新渲染。
- **非 React 环境**(工具函数、常量、类方法):可使用 `import { t } from 'i18next'`;此类用法不会随语言切换自动更新,仅在不依赖响应式更新的场景使用。
- 即使父组件已使用 `useTranslation()`,子组件仍应自行使用,以保证独立性。
- **专有名词**:品牌、产品、技术术语等可保留英文(如 API、React、TypeScript若有约定俗成的译法则使用翻译。
- **翻译键**:使用有层级、语义清晰的键名,如 `dashboard.overview.title`,并保持命名一致。
- **枚举与文案(常量中的 i18n**
各 feature 的 `constants.ts` 中常出现「枚举/状态 + 展示文案」或「成功/错误消息」,须统一约定以免遗漏 i18n、用法混乱
- **成功/错误/提示类消息**(如 `SUCCESS_MESSAGES``ERROR_MESSAGES`):常量值仅表示 **i18n 键**(与英文 fallback 同字面量)。展示时**必须**通过 `t()` 使用,例如 `toast.success(t(SUCCESS_MESSAGES.API_KEY_CREATED))``toast.error(t(ERROR_MESSAGES.UNEXPECTED))`**禁止**直接 `toast.success(SUCCESS_MESSAGES.xxx)` 当作最终文案。
- **状态/选项的 label**:在常量中统一用 **labelKey**(字符串,即 i18n 键),组件中通过 `t(config.labelKey)` 渲染;或约定用 `label` 存与 en 一致的 key 字符串,组件用 `t(config.label)`。同一 feature 内只采用一种方式,避免混用。
- **新增此类常量时**:同步在 `src/i18n/static-keys.ts` 中登记对应 key若项目用其做提取或确保文案以 `t('...')` 字面量形式出现以便扫描,避免遗漏翻译。
### 3.2 代码风格与类型
- **表达式**:禁止 2 层及以上嵌套三元表达式;改用 `if-else`、提前返回或抽取函数。单层三元可保留,但需简洁。
- **可读性**:控制函数圈复杂度,复杂逻辑拆成小函数;变量与函数命名需有意义,遵循驼峰等常规约定。
- **TypeScript**:避免 `any`,优先具体类型或 `unknown`;为参数与返回值显式标注类型;仅类型用途的导入使用 `import type { X } from '...'`
- **类型检查**:每次改动 TypeScript 或 TSX 代码后都要执行类型检查(如 `bun run typecheck`);若出现类型错误,须修复至无错误为止,不得遗留。
- **解构**:对象非必要不要进行解构,特别是组件的 props直接使用 `props.xxx` 更清晰,避免不必要的解构增加代码复杂度。
### 3.3 组件
- 使用函数式组件与 Hooks单一职责组件 props 须有明确类型(接口或类型别名)。
- **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。
- 单文件超过约 200 行时考虑拆分子组件或将逻辑抽到自定义 Hooks类型定义可与组件同文件或放在同模块的 `types` 中。
### 3.4 性能
- **React**:合理使用 `useMemo``useCallback` 减少无效重渲染;避免在渲染路径中创建新对象/数组;必要时使用 `React.memo`
- **代码分割**:使用 `React.lazy` 与动态 `import` 做按需加载,控制首屏与路由体积。
- **资源**:图片选用合适格式与尺寸,大列表考虑虚拟滚动(如 @tanstack/react-virtual大量图片考虑懒加载。
### 3.5 状态管理
- 使用 Zustand 的 `create` 定义 store并为 state 与 actions 定义清晰类型。
- 组件内优先用选择器订阅,避免整 store 订阅导致多余渲染,例如:`const user = useAuthStore((s) => s.auth.user)`
- 需持久化的状态在 store 内读写 localStorage并在初始化时恢复。
- Store 按功能放在 `src/stores/`,单文件职责清晰,命名表意明确。
### 3.6 API 请求
- **React Query**:数据获取用 `useQuery`,变更用 `useMutation`;为每个查询配置唯一 `queryKey`(建议数组形式、层级一致);在 `onSuccess` 中对相关 query 做 `invalidateQueries`,可配合乐观更新。服务端错误统一通过 `handleServerError` 处理(详见 [3.9 错误处理](#39-错误处理))。
- **Axios**:使用项目统一的 `api` 实例(含 `baseURL``headers``withCredentials: true`GET 默认请求去重,特殊请求可通过配置关闭;认证与通用错误在拦截器中处理。
### 3.7 表单
- 使用 React Hook Form + Zod在功能模块的 `lib/` 下定义 schema并用 `z.infer` 导出表单类型;`useForm` 配合 `@hookform/resolvers/zod` 做校验。
- 提交逻辑放在 `onSubmit`,展示加载与错误状态;成功后视场景重置表单或关闭弹窗。服务端校验错误映射到对应字段并展示(字段级错误展示方式见 [3.9 错误处理](#39-错误处理))。
### 3.8 路由
- 使用 TanStack Router路由文件位于 `src/routes/`,通过 `createFileRoute` 定义;搜索参数用 Zod schema + `validateSearch` 校验。
- 在 `beforeLoad` 中做认证与重定向,避免不必要的请求;嵌套结构用布局路由与 `_authenticated` 等前缀,子路由通过 `<Outlet />` 渲染。
- 导航使用 `useNavigate``Link`,保持类型安全,避免直接操作 `window.location`
### 3.9 错误处理
- **服务端错误**:统一使用 `handleServerError`,在 React Query 全局配置与拦截器中接入;按 HTTP 状态码给出合适提示,文案使用 i18n。
- **展示**:使用 `toast.error` 等统一方式;路由级错误由 `errorComponent` 承接,提供友好错误页并记录便于排查的信息。
- **表单**:校验与服务端错误映射到字段后,在字段下方展示;使用 `form.setError` 等与表单库一致的方式。
### 3.10 样式
- 以 Tailwind 工具类为主,动态类名用 `cn()` 合并;非动态场景避免内联样式。
- 响应式采用移动优先与 Tailwind 断点(`sm:``md:``lg:` 等);主题与暗色用 CSS 变量与 `dark:`,自定义样式集中在 `src/styles/`,组件内尽量少写自定义 CSS。
### 3.11 文件组织
- **功能模块**:置于 `src/features/<feature>/`,内含 `components/``lib/``hooks/`,以及按需的 `api.ts``types.ts``constants.ts`、入口组件等。
- **通用**:通用组件放 `src/components/`,通用工具与类型放 `src/lib/`;组件文件 PascalCase工具/类型文件 kebab-case 或 `types.ts`,类型使用 PascalCase 命名并 `export type`
### 3.12 可访问性
- 使用语义化 HTML`header``nav``main``footer`),表单用 `label` 关联输入。
- 保证键盘可操作与焦点顺序合理;必要时使用 ARIA`aria-label``aria-expanded``aria-hidden`);装饰性图标加 `aria-hidden="true"`,重要信息提供文本等价。
- 对比度满足 WCAG 2.1 AA正文至少 4.5:1
### 3.13 安全
- 认证与权限在路由与接口层校验;敏感操作增加二次确认等。
- 前后端均做数据校验(如 Zod不信任仅前端校验敏感信息不落前端存储配置用环境变量禁止硬编码密钥。
- 依赖 React 默认转义,慎用 `dangerouslySetInnerHTML`;跨域与 Cookie 使用 `withCredentials` 并按后端要求处理 CSRF。
### 3.14 测试
- 工具函数与纯逻辑优先单元测试Vitest测试文件 `*.test.ts`;组件用 React Testing Library 测交互与行为,避免测实现细节。
- 关键流程补充集成与 E2E如 MSW 模拟 API、Playwright/Cypress核心功能目标覆盖率 80% 以上,关注业务路径与关键分支。
### 3.15 依赖管理
- 使用 **Bun**`bun install``bun add <pkg>``bun add -d <pkg>``bun remove <pkg>``bun pm ls``bun update` 等。
- 新增依赖前评估维护情况、体积与许可;生产与开发依赖区分清楚,版本用 `^`/`~` 控制,定期更新以获取安全修复。
### 3.16 构建与部署
- 使用 Rsbuild配置见 `rsbuild.config.ts`;脚本以 `package.json` 为准(如 `bun run dev``bun run build``bun run typecheck``bun run lint``bun run format`),包管理见 [3.15 依赖管理](#315-依赖管理)。
- 代码分割与懒加载策略见 [3.4 性能](#34-性能);资源使用合适格式与压缩,环境变量用 `.env` 且以 `VITE_` 前缀,不在代码中硬编码。
- **发布前**:执行 typecheck、lint、format 检查,完成生产构建并检查产物体积与环境变量配置。
---
## 四、协作与提交
- 提交信息清晰、符合项目约定,描述变更内容与原因,中英文统一即可。
- 变更需经过代码审查,符合本文档规范,并关注质量、性能与安全。
- 重大功能或规范变更时更新相关文档与 `AGENTS.md`
---
## 更新日志
- **2026-01-28**:初始版本(国际化、代码、组件、类型等基础规范)。
- **2026-01-28**补充状态管理、API、表单、路由、错误处理、样式、文件组织、可访问性、安全、测试、依赖与构建部署规范。
- **2026-01-29**:重组文档结构,合并重复内容,明确主次与交叉引用。
- **2026-01-31**:在 3.2 中补充「类型检查」要求:改动 TS/TSX 后须执行 typecheck 并修复至无错。

2911
web/default/bun.lock vendored

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "hugeicons",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "inverted",
"menuAccent": "subtle",
"registries": {
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
}
}

7
web/default/cz.yaml vendored
View File

@ -1,7 +0,0 @@
---
commitizen:
name: cz_conventional_commits
tag_format: v$version
update_changelog_on_bump: true
version_provider: npm
version_scheme: semver

View File

@ -1,67 +0,0 @@
import globals from 'globals'
import js from '@eslint/js'
import pluginQuery from '@tanstack/eslint-plugin-query'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'
export default defineConfig(
{ ignores: ['dist', 'src/components/ui'] },
{
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
...pluginQuery.configs['flat/recommended'],
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/incompatible-library': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'no-console': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
disallowTypeAnnotations: false,
},
],
'no-duplicate-imports': 'error',
},
},
{
files: ['src/routes/**/*.{ts,tsx}'],
plugins: {
'react-refresh': reactRefresh,
},
rules: {
'react-refresh/only-export-components': 'off',
},
}
)

View File

@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>New API</title>
<meta name="title" content="New API" />
<meta
name="description"
content="Unified AI API gateway and admin dashboard."
/>
<meta name="theme-color" content="#fff" />
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,8 +0,0 @@
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
ignore: ['src/components/ui/**', 'src/routeTree.gen.ts'],
ignoreDependencies: ["tailwindcss", "tw-animate-css"]
};
export default config;

View File

@ -1,4 +0,0 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -1,113 +0,0 @@
{
"name": "newapi-web",
"private": false,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"build:check": "tsc -b && rsbuild build",
"typecheck": "tsc -b",
"lint": "eslint .",
"preview": "rsbuild preview",
"format:check": "prettier --check .",
"format": "prettier --write .",
"i18n:sync": "node scripts/sync-i18n.mjs",
"knip": "knip"
},
"dependencies": {
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons": "^4.0.3",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.23",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"@visactor/react-vchart": "^2.0.13",
"@visactor/vchart": "^2.0.13",
"ai": "^6.0.27",
"auto-skeleton-react": "^1.0.5",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2",
"lucide-react": "^1.7.0",
"motion": "^12.38.0",
"nanoid": "^5.1.6",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.0",
"react-i18next": "^16.5.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-top-loading-bar": "^3.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"sse.js": "^2.7.2",
"streamdown": "^2.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rsbuild/core": "^2.0.1",
"@rsbuild/plugin-react": "^2.0.0",
"@tanstack/eslint-plugin-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/router-plugin": "^1.167.23",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@xyflow/react": "^12.10.2",
"embla-carousel-react": "^8.6.0",
"eslint": "^10.1.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"knip": "^6.0.6",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"shadcn": "^3.7.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.2"
}
}

View File

@ -1,5 +0,0 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -1,103 +0,0 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/rspack'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
const serverUrl =
process.env.VITE_REACT_APP_SERVER_URL ||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
'http://localhost:3000'
const isProd = envMode === 'production'
const devProxy = Object.fromEntries(
(['/api', '/mj', '/pg'] as const).map((key) => [
key,
{ target: serverUrl, changeOrigin: true },
]),
) as Record<string, { target: string; changeOrigin: boolean }>
return {
plugins: [pluginReact()],
// Rsbuild 2: replaces deprecated `performance.chunkSplit` (RSPack 2 aligned)
splitChunks: {
preset: 'default',
cacheGroups: {
'vendor-react': {
test: /node_modules[\\/](react|react-dom)[\\/]/,
name: 'vendor-react',
chunks: 'all',
priority: 0,
enforce: true,
},
'vendor-radix': {
test: /node_modules[\\/]@radix-ui[\\/]/,
name: 'vendor-radix',
chunks: 'all',
priority: 0,
enforce: true,
},
'vendor-tanstack': {
test: /node_modules[\\/]@tanstack[\\/]/,
name: 'vendor-tanstack',
chunks: 'all',
priority: 0,
enforce: true,
},
},
},
source: {
entry: {
index: './src/main.tsx',
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
html: {
template: './index.html',
},
server: {
host: '0.0.0.0',
proxy: devProxy,
},
output: {
// Production optimizations
minify: isProd,
target: 'web',
distPath: {
root: 'dist',
},
// Rely on Rsbuild default legalComments ("linked" → per-chunk *.LICENSE.txt) in all modes.
// Do not set "none" in production: that strips minifier-preserved third-party notices and
// extracted license files, which some distributions require for open-source compliance.
},
performance: {
// Remove console in production
removeConsole: isProd ? ['log'] : false,
// Speed up repeated `rsbuild build` (local + CI when node_modules/.cache is preserved).
// @see https://v2.rsbuild.dev/config/performance/build-cache
buildCache: {
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
},
},
tools: {
rspack: {
plugins: [
tanstackRouter({
target: 'react',
// Dev: avoid per-route async chunks (reduces white flash on navigation + faster HMR feedback).
// Prod: keep route-based code splitting.
autoCodeSplitting: isProd,
}),
],
},
},
}
})

View File

@ -1,196 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
// This script is executed from the web/ package root (see package.json script).
const LOCALES_DIR = path.resolve('src/i18n/locales')
const FALLBACK_COMPARE_LOCALE = 'en' // used for "still English" detection only
const OBFUSCATED_KEYS = [
{
runtime: ['footer', 'new' + 'api', 'projectAttributionSuffix'].join('.'),
serialized: 'footer.new\\u0061pi.projectAttributionSuffix',
},
]
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function stableStringify(obj) {
let text = JSON.stringify(obj, null, 2)
for (const key of OBFUSCATED_KEYS) {
text = text.replaceAll(`"${key.runtime}":`, `"${key.serialized}":`)
}
return text + '\n'
}
function countLeafKeys(obj) {
if (Array.isArray(obj)) return obj.length
if (!isPlainObject(obj)) return 0
let count = 0
for (const k of Object.keys(obj)) {
const v = obj[k]
if (isPlainObject(v) || Array.isArray(v)) count += countLeafKeys(v)
else count += 1
}
return count
}
function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) {
// If base is an object, we keep base's key order and recurse.
if (isPlainObject(base)) {
const out = {}
const t = isPlainObject(target) ? target : {}
const f = isPlainObject(fill) ? fill : {}
for (const key of Object.keys(base)) {
const nextPath = [...currentPath, key]
if (Object.prototype.hasOwnProperty.call(t, key)) {
out[key] = reorderLikeBase(base[key], t[key], f[key], extras, missing, nextPath)
} else {
missing.push(nextPath.join('.'))
out[key] = reorderLikeBase(base[key], undefined, f[key], extras, missing, nextPath)
}
}
for (const key of Object.keys(t)) {
if (!Object.prototype.hasOwnProperty.call(base, key)) {
const nextPath = [...currentPath, key].join('.')
extras[nextPath] = t[key]
}
}
return out
}
// For arrays: prefer target if it's also an array; otherwise use base.
if (Array.isArray(base)) {
if (Array.isArray(target)) return target
if (Array.isArray(fill)) return fill
return base
}
// For primitives: prefer target if defined, else base.
return target === undefined ? (fill ?? base) : target
}
function isLikelyUntranslated({ locale, baseValue, value }) {
if (typeof value !== 'string' || typeof baseValue !== 'string') return false
if (value !== baseValue) return false
// Skip short tokens / acronyms / ids
const s = baseValue.trim()
if (s.length < 6) return false
if (!/[A-Za-z]{3,}/.test(s)) return false
// For locales with non-latin scripts, equality with EN is a strong signal.
if (locale === 'ja' || locale === 'zh') return true
if (locale === 'ru') return true
// For fr/vi: still useful but noisier; keep it conservative.
if (locale === 'fr' || locale === 'vi') return /\b(the|and|or|to|with|please)\b/i.test(s)
return false
}
async function main() {
const entries = await fs.readdir(LOCALES_DIR, { withFileTypes: true })
const localeFiles = entries
.filter((e) => e.isFile() && e.name.endsWith('.json'))
.map((e) => e.name)
.sort((a, b) => a.localeCompare(b))
// Auto-pick base locale as the one with the most leaf keys under translation (most "rich").
const parsedByLocale = {}
for (const filename of localeFiles) {
const locale = filename.replace(/\.json$/i, '')
const raw = await fs.readFile(path.join(LOCALES_DIR, filename), 'utf8')
parsedByLocale[locale] = JSON.parse(raw)
}
const baseLocale = Object.keys(parsedByLocale)
.map((locale) => {
const json = parsedByLocale[locale]
const trans = json?.translation ?? {}
return { locale, score: countLeafKeys(trans) }
})
.sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale
if (!baseLocale) throw new Error('No locale files found.')
const baseFile = `${baseLocale}.json`
const baseJson = parsedByLocale[baseLocale]
const compareJson = parsedByLocale[FALLBACK_COMPARE_LOCALE] ?? baseJson
const report = {
base: baseFile,
locales: {},
}
const extrasDir = path.join(LOCALES_DIR, '_extras')
const reportsDir = path.join(LOCALES_DIR, '_reports')
await fs.mkdir(extrasDir, { recursive: true })
await fs.mkdir(reportsDir, { recursive: true })
for (const filename of localeFiles) {
const locale = filename.replace(/\.json$/i, '')
const full = path.join(LOCALES_DIR, filename)
const json = parsedByLocale[locale]
const extras = {}
const missing = []
const fixed = reorderLikeBase(baseJson, json, compareJson, extras, missing)
// Untranslated scan (translation namespace only)
const untranslated = {}
const compareTrans = compareJson?.translation ?? {}
const trans = fixed?.translation ?? {}
if (
isPlainObject(compareTrans) &&
isPlainObject(trans) &&
locale !== FALLBACK_COMPARE_LOCALE &&
locale !== baseLocale
) {
for (const k of Object.keys(compareTrans)) {
const baseValue = compareTrans[k]
const value = trans[k]
if (isLikelyUntranslated({ locale, baseValue, value })) {
untranslated[k] = value
}
}
}
report.locales[locale] = {
file: filename,
missingCount: missing.length,
extrasCount: Object.keys(extras).length,
untranslatedCount: Object.keys(untranslated).length,
}
if (Object.keys(extras).length > 0) {
await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8')
}
if (Object.keys(untranslated).length > 0) {
await fs.writeFile(
path.join(reportsDir, `${locale}.untranslated.json`),
stableStringify(untranslated),
'utf8',
)
}
// Rewrite locale file in base order (even for en to normalize formatting)
await fs.writeFile(full, stableStringify(fixed), 'utf8')
}
await fs.writeFile(path.join(reportsDir, '_sync-report.json'), stableStringify(report), 'utf8')
console.log(`i18n sync done. Report: ${path.join(reportsDir, '_sync-report.json')}`)
}
main().catch((err) => {
console.error(err)
process.exitCode = 1
})

View File

@ -1,28 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconDiscord({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Discord</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
<path d='M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
<path d='M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3' />
<path d='M7 16.5c3.5 1 6.5 1 10 0' />
</svg>
)
}

View File

@ -1,33 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconDocker({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Docker</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z' />
<path d='M5 10h3v3h-3z' />
<path d='M8 10h3v3h-3z' />
<path d='M11 10h3v3h-3z' />
<path d='M8 7h3v3h-3z' />
<path d='M11 7h3v3h-3z' />
<path d='M11 4h3v3h-3z' />
<path d='M4.571 18c1.5 0 2.047 -.074 2.958 -.78' />
<path d='M10 16l0 .01' />
</svg>
)
}

View File

@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconFacebook({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Facebook</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3' />
</svg>
)
}

View File

@ -1,27 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconFigma({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Figma</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M15 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0' />
<path d='M6 3m0 3a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v0a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3z' />
<path d='M9 9a3 3 0 0 0 0 6h3m-3 0a3 3 0 1 0 3 3v-15' />
</svg>
)
}

View File

@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconGithub({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>GitHub</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5' />
</svg>
)
}

View File

@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconGitlab({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>GitLab</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z' />
</svg>
)
}

View File

@ -1,28 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconGmail({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Gmail</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M16 20h3a1 1 0 0 0 1 -1v-14a1 1 0 0 0 -1 -1h-3v16z' />
<path d='M5 20h3v-16h-3a1 1 0 0 0 -1 1v14a1 1 0 0 0 1 1z' />
<path d='M16 4l-4 4l-4 -4' />
<path d='M4 6.5l8 7.5l8 -7.5' />
</svg>
)
}

View File

@ -1,50 +0,0 @@
/*
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 { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconLinuxDo({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 16 16'
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
className={cn(className)}
{...props}
>
<title>LinuxDO</title>
<g>
<path
d='M7.44 0h.13c.09 0 .19 0 .28 0h.43c.09 0 .18 0 .27 0h.25c.09 0 .17.03.26.08.15.03.29.06.44.08 1.97.38 3.78 1.47 4.95 3.11.04.06.09.12.13.18.67.96 1.15 2.11 1.3 3.28v.26c0 .15 0 .29 0 .44v.13c0 .09 0 .19 0 .28v.43c0 .09 0 .18 0 .27v.25c0 .09-.03.17-.08.26-.03.15-.06.29-.08.44-.38 1.97-1.47 3.78-3.11 4.95-.06.04-.12.09-.18.13-.96.67-2.11 1.15-3.28 1.3h-.26c-.15 0-.29 0-.44 0h-.13c-.09 0-.19 0-.28 0h-.43c-.09 0-.18 0-.27 0h-.25c-.09 0-.17-.03-.26-.08-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11L.59 12.6c-.67-.96-1.15-2.11-1.3-3.28v-.26c0-.15 0-.29 0-.44v-.13c0-.09 0-.19 0-.28v-.43c0-.09 0-.18 0-.27v-.25c0-.09.03-.17.08-.26.03-.15.06-.29.08-.44.38-1.97 1.47-3.78 3.11-4.95.06-.04.12-.09.18-.13C4.42.73 5.57.26 6.74.1c.26-.03.41-.1.7-.1Z'
fill='#efefef'
/>
<path
d='M1.27 11.33h13.45c-.94 1.89-2.51 3.21-4.51 3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z'
fill='#feb005'
/>
<path
d='M12.54 1.99c.87.7 1.82 1.59 2.18 2.68H1.27c.87-1.74 2.33-3.13 4.2-3.78 2.44-.79 5-.47 7.07 1.1Z'
fill='#1d1d1f'
/>
</g>
</svg>
)
}

View File

@ -1,30 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconMedium({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Medium</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
<path d='M8 9h1l3 3l3 -3h1' />
<path d='M8 15l2 0' />
<path d='M14 15l2 0' />
<path d='M9 9l0 6' />
<path d='M15 9l0 6' />
</svg>
)
}

View File

@ -1,28 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconNotion({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Notion</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M11 17.5v-6.5h.5l4 6h.5v-6.5' />
<path d='M19.077 20.071l-11.53 .887a1 1 0 0 1 -.876 -.397l-2.471 -3.294a1 1 0 0 1 -.2 -.6v-10.741a1 1 0 0 1 .923 -.997l11.389 -.876a2 2 0 0 1 1.262 .33l1.535 1.023a2 2 0 0 1 .891 1.664v12.004a1 1 0 0 1 -.923 .997z' />
<path d='M4.5 5.5l2.5 2.5' />
<path d='M20 7l-13 1v12.5' />
</svg>
)
}

View File

@ -1,26 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconSkype({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Skype</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z' />
<path d='M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -1 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5' />
</svg>
)
}

View File

@ -1,28 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconSlack({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Slack</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M12 12v-6a2 2 0 0 1 4 0v6m0 -2a2 2 0 1 1 2 2h-6' />
<path d='M12 12h6a2 2 0 0 1 0 4h-6m2 0a2 2 0 1 1 -2 2v-6' />
<path d='M12 12v6a2 2 0 0 1 -4 0v-6m0 2a2 2 0 1 1 -2 -2h6' />
<path d='M12 12h-6a2 2 0 0 1 0 -4h6m-2 0a2 2 0 1 1 2 -2v6' />
</svg>
)
}

View File

@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconStripe({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Stripe</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M11.453 8.056c0 -.623 .518 -.979 1.442 -.979c1.69 0 3.41 .343 4.605 .923l.5 -4c-.948 -.449 -2.82 -1 -5.5 -1c-1.895 0 -3.373 .087 -4.5 1c-1.172 .956 -2 2.33 -2 4c0 3.03 1.958 4.906 5 6c1.961 .69 3 .743 3 1.5c0 .735 -.851 1.5 -2 1.5c-1.423 0 -3.963 -.609 -5.5 -1.5l-.5 4c1.321 .734 3.474 1.5 6 1.5c2 0 3.957 -.468 5.084 -1.36c1.263 -.979 1.916 -2.268 1.916 -4.14c0 -3.096 -1.915 -4.547 -5 -5.637c-1.646 -.605 -2.544 -1.07 -2.544 -1.807z' />
</svg>
)
}

View File

@ -1,25 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconTelegram({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Telegram</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4' />
</svg>
)
}

View File

@ -1,27 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconTrello({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Trello</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
<path d='M7 7h3v10h-3z' />
<path d='M14 7h3v6h-3z' />
</svg>
)
}

View File

@ -1,38 +0,0 @@
/*
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 { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconWeChat({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 1024 1024'
xmlns='http://www.w3.org/2000/svg'
width='20'
height='20'
className={cn('fill-current', className)}
{...props}
>
<title>WeChat</title>
<path d='M690.1 377.4c5.9 0 11.8.2 17.6.5C683.3 249.2 549.4 150.8 387.8 150.8 209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-.1 17.8-.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8zm-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1zm-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z' />
<path d='M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-.9-4.4-1.4-6.6-.3-1.2-7.6-28.3-12.2-45.3-.5-1.9-.9-3.8-.9-5.7.1-5.9 3.1-11.2 7.6-14.5zm-266.5-205.5c-19.9 0-36-16.1-36-35.9s16.1-35.9 36-35.9 36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9zm179.9 0c-19.9 0-36-16.1-36-35.9s16.1-35.9 36-35.9 36 16.1 36 35.9c-.1 19.8-16.2 35.9-36 35.9z' />
</svg>
)
}

View File

@ -1,26 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconWhatsapp({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>WhatsApp</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9' />
<path d='M9 10a.5 .5 0 0 0 1 0v-1a.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a.5 .5 0 0 0 0 -1h-1a.5 .5 0 0 0 0 1' />
</svg>
)
}

View File

@ -1,26 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconZoom({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
className={cn('[&>path]:stroke-current', className)}
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<title>Zoom</title>
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
<path d='M17.011 9.385v5.128l3.989 3.487v-12z' />
<path d='M3.887 6h10.08c1.468 0 3.033 1.203 3.033 2.803v8.196a.991 .991 0 0 1 -.975 1h-10.373c-1.667 0 -2.652 -1.5 -2.652 -3l.01 -8a.882 .882 0 0 1 .208 -.71a.841 .841 0 0 1 .67 -.287z' />
</svg>
)
}

View File

@ -1,18 +0,0 @@
export { IconDiscord } from './icon-discord'
export { IconDocker } from './icon-docker'
export { IconFacebook } from './icon-facebook'
export { IconFigma } from './icon-figma'
export { IconGithub } from './icon-github'
export { IconGitlab } from './icon-gitlab'
export { IconGmail } from './icon-gmail'
export { IconLinuxDo } from './icon-linuxdo'
export { IconMedium } from './icon-medium'
export { IconNotion } from './icon-notion'
export { IconSkype } from './icon-skype'
export { IconSlack } from './icon-slack'
export { IconStripe } from './icon-stripe'
export { IconTelegram } from './icon-telegram'
export { IconTrello } from './icon-trello'
export { IconWeChat } from './icon-wechat'
export { IconWhatsapp } from './icon-whatsapp'
export { IconZoom } from './icon-zoom'

View File

@ -1,41 +0,0 @@
import { type SVGProps } from 'react'
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
width={77}
height={24}
viewBox='0 0 77 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
fill='#1F0256'
/>
<path
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
fill='url(#paint0_linear_26568_214324)'
/>
<path
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
fill='#1F0256'
/>
<defs>
<linearGradient
id='paint0_linear_26568_214324'
x1={16.4087}
y1={-1.75881}
x2={-7.88473}
y2={22.5365}
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#17CCFC' />
<stop offset={0.5} stopColor='#5D31FF' />
<stop offset={1} stopColor='#F35AFF' />
</linearGradient>
</defs>
</svg>
)
}

View File

@ -1,23 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
id='clerk'
height='24'
width='24'
className={cn('[&>path]:fill-foreground', className)}
{...props}
>
<title>Clerk</title>
<path
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
strokeWidth='1'
></path>
</svg>
)
}

View File

@ -1,110 +0,0 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
import { type Direction } from '@/context/direction-provider'
type IconDirProps = SVGProps<SVGSVGElement> & {
dir: Direction
}
export function IconDir({ dir, className, ...props }: IconDirProps) {
return (
<svg
data-name={`icon-dir-${dir}`}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
{...props}
>
<path
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
opacity={0.15}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 14.88L17.78 14.88'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 22.09L16.08 22.09'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 18.38L14.93 18.38'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M12.06 6.14L17.78 6.14'
/>
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
</g>
<path
fill='none'
opacity={0.62}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M29.41 7.4L34.67 7.4'
/>
<rect
x={28.76}
y={11.21}
width={26.03}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.44}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={17.01}
width={44.25}
height={13.48}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={33.57}
width={44.25}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.21}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={41.32}
width={36.21}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}

View File

@ -1,131 +0,0 @@
import { type SVGProps } from 'react'
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-layout-compact'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={5.84}
y={5.2}
width={4}
height={40}
rx={2}
ry={2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
<path
fill='none'
opacity={0.66}
strokeWidth='2px'
d='M7.26 11.56L8.37 11.56'
/>
<path
fill='none'
opacity={0.51}
strokeWidth='2px'
d='M7.26 14.49L8.37 14.49'
/>
<path
fill='none'
opacity={0.52}
strokeWidth='2px'
d='M7.26 17.39L8.37 17.39'
/>
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
</g>
<path
fill='none'
opacity={0.75}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M15.81 14.49L22.89 14.49'
/>
<rect
x={14.93}
y={18.39}
width={22.19}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.5}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={5.89}
width={59.16}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.9}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={24.22}
width={32.68}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={59.05}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={54.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={63.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={67.54}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={62.16} cy={18.63} r={7.5} />
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show More