Compare commits
38 Commits
v0.13.2
...
v1.0.0-rc.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 446a8420f5 | |||
| dc8deb0c24 | |||
| f8cf9c57c4 | |||
| 0f9f094a48 | |||
| 9acf5fecae | |||
| 8b2b03d276 | |||
| dac55f0fde | |||
| 938dc9522b | |||
| 5114ad0677 | |||
| d46df94f05 | |||
| aa730395f1 | |||
| d2b30dfc95 | |||
| 987b7ecd22 | |||
| 5f86839c7e | |||
| 8f3c41ae77 | |||
| 8bff691089 | |||
| 22fd1741ab | |||
| 9b0ec8ed48 | |||
| 95648353e4 | |||
| 2f8637048e | |||
| b2232f4355 | |||
| b44faec66b | |||
| 3b592895c6 | |||
| e0b6eb3a59 | |||
| 6f57dcd2f5 | |||
| 8ca103342d | |||
| 22ae14f0d7 | |||
| f982544825 | |||
| 438410708f | |||
| 75af3db11f | |||
| db48108d21 | |||
| 22ef5b2f80 | |||
| 28f7e9eb2e | |||
| fc377dae3e | |||
| df14a0bf18 | |||
| c609cb13b2 | |||
| a42b397607 | |||
| 9f8a4ec050 |
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: classic-to-default-sync
|
||||
description: Inspect a given commit's web/classic changes and sync all features/fixes to web/default. Use when the user provides a commit ID and wants to audit whether web/default already has the same features as web/classic, port missing features, improve suboptimal implementations, fix bugs, and remove redundant code. Trigger phrases include: "/classic-to-default-sync <hash>", "classic-to-default-sync <hash>", "sync classic to default", "port from classic", "compare classic commit", "classic 和 default 对比", "把这次 classic 的修改同步到 default", "查看这次提交 classic 中的修改并同步", or any request supplying a commit hash together with classic/default comparison intent.
|
||||
---
|
||||
|
||||
# Classic-to-Default Sync
|
||||
|
||||
Given a **commit ID**, audit all `web/classic` changes and ensure `web/default` reaches feature parity with the best possible implementation.
|
||||
|
||||
## Input
|
||||
|
||||
The user must supply a `<commit-id>`.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Extract classic diff
|
||||
|
||||
```bash
|
||||
git show <commit-id> -- web/classic
|
||||
```
|
||||
|
||||
Read every changed file in `web/classic`. Identify the **logical changes** (new features, UI/UX improvements, bug fixes, config tweaks, removed dead code, etc.) — not just line diffs.
|
||||
|
||||
### Step 2 — Map to default counterparts
|
||||
|
||||
For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that:
|
||||
|
||||
- `web/classic` uses **React 18 + Vite + Semi Design**
|
||||
- `web/default` uses **React 19 + Rsbuild + Base 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.)
|
||||
- Base 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
|
||||
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: i18n-translate
|
||||
description: >-
|
||||
Complete and maintain frontend i18n translations for this project. Covers
|
||||
finding missing translation keys, detecting untranslated entries, and adding
|
||||
translations for all supported locales (en, zh, fr, ja, ru, vi). Use when the
|
||||
user asks to add translations, fix i18n, complete missing translations, or
|
||||
when new UI text needs to be internationalized.
|
||||
---
|
||||
|
||||
# Frontend i18n Translation Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json`
|
||||
- Format: flat JSON under `"translation"` key, keys are English source strings
|
||||
- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)
|
||||
- Sync script: `bun run i18n:sync` (from `web/default/`)
|
||||
- All `t()` calls must have corresponding keys in every locale file
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Run sync and read report
|
||||
|
||||
```bash
|
||||
cd web/default && bun run i18n:sync
|
||||
```
|
||||
|
||||
Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).
|
||||
|
||||
### Step 2: Find missing keys (used in code but not in locale files)
|
||||
|
||||
Create and run `web/default/scripts/find-missing-keys.mjs`:
|
||||
|
||||
```javascript
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||
const SRC_DIR = path.resolve('src')
|
||||
|
||||
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
|
||||
const enKeys = new Set(Object.keys(en.translation))
|
||||
|
||||
const tCallRegex = /\bt\(\s*['"`]([^'"`\n]+?)['"`]\s*[,)]/g
|
||||
const tCallMultilineRegex = /\bt\(\s*['"`]([^'"`]+?)['"`]\s*\)/g
|
||||
|
||||
async function walkDir(dir) {
|
||||
const files = []
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (['node_modules', '.git', 'locales', '_reports', '_extras'].includes(entry.name)) continue
|
||||
files.push(...(await walkDir(fullPath)))
|
||||
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const files = await walkDir(SRC_DIR)
|
||||
const missingKeys = new Map()
|
||||
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
const relPath = path.relative(SRC_DIR, file)
|
||||
for (const regex of [tCallRegex, tCallMultilineRegex]) {
|
||||
regex.lastIndex = 0
|
||||
let match
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
const key = match[1]
|
||||
if (key.startsWith('{{') || key.includes('${')) continue
|
||||
if (!enKeys.has(key)) {
|
||||
if (!missingKeys.has(key)) missingKeys.set(key, [])
|
||||
missingKeys.get(key).push(relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingKeys.size === 0) {
|
||||
console.log('All t() keys found in en.json!')
|
||||
} else {
|
||||
console.log(`Found ${missingKeys.size} missing keys:\n`)
|
||||
for (const [key, files] of [...missingKeys.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
console.log(` "${key}"`)
|
||||
for (const f of [...new Set(files)]) console.log(` -> ${f}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Find untranslated entries (value equals English)
|
||||
|
||||
Create and run `web/default/scripts/find-untranslated.mjs`:
|
||||
|
||||
```javascript
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||
const en = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, 'en.json'), 'utf8'))
|
||||
const enTrans = en.translation
|
||||
|
||||
// Brand names, URLs, technical terms — skip these
|
||||
const skipPatterns = [
|
||||
/^https?:\/\//, /^smtp\./, /^socks5:/, /^name@/, /^noreply@/,
|
||||
/^org-/, /^price_/, /^whsec_/, /^edit_this$/, /^my-status$/,
|
||||
/^_copy$/, /^gpt-/, /^checkout\./, /^footer\./, /^\[?\{/,
|
||||
/^"default/, /^\/status\//, /^\/your\//, /^example\.com/,
|
||||
/^AZURE_/, /^AccessKey/, /^OAuth/, /^Client /, /^Webhook URL/,
|
||||
/^API URL$/, /^Well-Known/, /^Worker URL$/, /^Uptime Kuma/,
|
||||
/^New API/, /^Baidu V2$/, /^Zhipu V4$/, /^Quota:$/,
|
||||
]
|
||||
|
||||
const brandNames = new Set([
|
||||
'AIGC2D','Anthropic','API2GPT','Claude','Cloudflare','Cohere','DeepSeek',
|
||||
'Discord','DoubaoVideo','FastGPT','Gemini','GitHub','Jimeng','JustSong',
|
||||
'LingYiWanWu','LinuxDO','Midjourney','MidjourneyPlus','MiniMax','Mistral',
|
||||
'MokaAI','Moonshot','NewAPI','OhMyGPT','Ollama','OpenAI','OpenAIMax',
|
||||
'OpenRouter','Passkey','Perplexity','QuantumNous','Replicate','SiliconFlow',
|
||||
'Stripe','Submodel','SunoAPI','Telegram','Tencent','Vertex AI','VolcEngine',
|
||||
'WeChat','Xinference','Xunfei','AI Proxy','One API',
|
||||
])
|
||||
|
||||
const locales = ['fr', 'ja', 'ru', 'zh', 'vi']
|
||||
|
||||
for (const locale of locales) {
|
||||
const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))
|
||||
const locTrans = locFile.translation
|
||||
const untranslated = {}
|
||||
|
||||
for (const [key, enVal] of Object.entries(enTrans)) {
|
||||
const locVal = locTrans[key]
|
||||
if (locVal === undefined || locVal !== enVal) continue
|
||||
if (brandNames.has(key)) continue
|
||||
if (skipPatterns.some(p => p.test(key))) continue
|
||||
if (typeof enVal === 'string' && enVal.length < 4) continue
|
||||
if (/[a-zA-Z]{3,}/.test(String(enVal))) untranslated[key] = enVal
|
||||
}
|
||||
|
||||
const count = Object.keys(untranslated).length
|
||||
if (count > 0) {
|
||||
console.log(`\n=== ${locale} (${count} untranslated) ===`)
|
||||
for (const [k, v] of Object.entries(untranslated))
|
||||
console.log(` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
} else {
|
||||
console.log(`\n=== ${locale}: all translated ===`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add translations
|
||||
|
||||
Create `web/default/scripts/add-missing-keys.mjs` with this structure:
|
||||
|
||||
```javascript
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const LOCALES_DIR = path.resolve('src/i18n/locales')
|
||||
|
||||
function stableStringify(obj) {
|
||||
return JSON.stringify(obj, null, 2) + '\n'
|
||||
}
|
||||
|
||||
const newKeys = {
|
||||
en: { /* "key": "English value" */ },
|
||||
zh: { /* "key": "中文翻译" */ },
|
||||
fr: { /* "key": "Traduction française" */ },
|
||||
ja: { /* "key": "日本語翻訳" */ },
|
||||
ru: { /* "key": "Русский перевод" */ },
|
||||
vi: { /* "key": "Bản dịch tiếng Việt" */ },
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let totalAdded = 0
|
||||
|
||||
for (const [locale, trans] of Object.entries(newKeys)) {
|
||||
const filePath = path.join(LOCALES_DIR, `${locale}.json`)
|
||||
const json = JSON.parse(await fs.readFile(filePath, 'utf8'))
|
||||
|
||||
let count = 0
|
||||
for (const [key, value] of Object.entries(trans)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(json.translation, key)) {
|
||||
json.translation[key] = value
|
||||
count++
|
||||
} else if (json.translation[key] !== value) {
|
||||
json.translation[key] = value
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
json.translation = Object.fromEntries(
|
||||
Object.entries(json.translation).sort(([a], [b]) => a.localeCompare(b))
|
||||
)
|
||||
await fs.writeFile(filePath, stableStringify(json), 'utf8')
|
||||
}
|
||||
|
||||
console.log(`${locale}: ${count} translations applied`)
|
||||
totalAdded += count
|
||||
}
|
||||
|
||||
console.log(`\nTotal: ${totalAdded} translations applied`)
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error(err); process.exitCode = 1 })
|
||||
```
|
||||
|
||||
Populate the `newKeys` object with actual translations for each locale.
|
||||
|
||||
### Step 5: Verify and clean up
|
||||
|
||||
```bash
|
||||
cd web/default
|
||||
node scripts/add-missing-keys.mjs # apply translations
|
||||
node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"
|
||||
bun run i18n:sync # normalize file order
|
||||
```
|
||||
|
||||
Delete temporary scripts after completion.
|
||||
|
||||
## Translation Guidelines
|
||||
|
||||
| Language | Code | Notes |
|
||||
|----------|------|-------|
|
||||
| English | en | Base locale, key = value |
|
||||
| Chinese | zh | Fallback locale, must be complete |
|
||||
| French | fr | Many English cognates are valid (e.g., "Configuration") |
|
||||
| Japanese | ja | Use katakana for technical loanwords |
|
||||
| Russian | ru | Use formal register |
|
||||
| Vietnamese | vi | Use standard Vietnamese |
|
||||
|
||||
**Keep as English (do not translate):**
|
||||
- Brand/product names (OpenAI, Claude, Gemini, etc.)
|
||||
- URLs and email placeholders
|
||||
- Technical identifiers (JSON keys, API paths, model names)
|
||||
- Code-like strings (gpt-3.5-turbo, price_xxx, etc.)
|
||||
|
||||
**Always translate:**
|
||||
- UI labels, button text, error messages, descriptions
|
||||
- Time units (hours, minutes, months, years)
|
||||
- Action words (Move, Show, Delete, etc.)
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. All scripts run from `web/default/` directory
|
||||
2. Use `node scripts/xxx.mjs` (ESM format with top-level await)
|
||||
3. Sort keys alphabetically when writing locale files
|
||||
4. Always run `bun run i18n:sync` as the final step
|
||||
5. Delete temporary scripts after completion
|
||||
6. The `{{variable}}` placeholders in keys must be preserved in all translations
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: shadcn-ui
|
||||
description: >-
|
||||
Give the assistant project-aware shadcn/ui context: components.json,
|
||||
composition patterns, CLI, registries, theming, and MCP. Use when working on
|
||||
web/default UI, shadcn components, or presets. Overview aligns with
|
||||
https://ui.shadcn.com/docs/skills.md; full upstream skill text is vendored
|
||||
under vendor/shadcn/.
|
||||
---
|
||||
|
||||
<!-- Canonical overview: https://ui.shadcn.com/docs/skills.md -->
|
||||
|
||||
# Skills (shadcn/ui)
|
||||
|
||||
Skills give AI assistants project-aware context about shadcn/ui. When used, the assistant knows how to find, install, compose, and customize components using the correct APIs and patterns for your project.
|
||||
|
||||
For example, you can ask:
|
||||
|
||||
- _"Add a login form with email and password fields."_
|
||||
- _"Create a settings page with a form for updating profile information."_
|
||||
- _"Build a dashboard with a sidebar, stats cards, and a data table."_
|
||||
- _"Switch to --preset [CODE]"_
|
||||
- _"Can you add a hero from @tailark?"_
|
||||
|
||||
The skill reads your project's `components.json` and provides your framework, aliases, installed components, icon library, and base library so it can generate correct code on the first try.
|
||||
|
||||
---
|
||||
|
||||
## Install (ecosystem vs this repo)
|
||||
|
||||
Official install from [Skills — shadcn/ui](https://ui.shadcn.com/docs/skills.md):
|
||||
|
||||
```bash
|
||||
npx skills add shadcn/ui
|
||||
```
|
||||
|
||||
That installs the skill where the `skills` CLI is available. **This repository** keeps the same intent under `.agents/skills/shadcn-ui/` (overview here + **vendored** upstream docs in [`vendor/shadcn/`](./vendor/shadcn/)) and runs the shadcn CLI from the frontend app root:
|
||||
|
||||
```bash
|
||||
cd web/default && bunx shadcn@latest info --json
|
||||
```
|
||||
|
||||
Learn more about skills at [skills.sh](https://skills.sh).
|
||||
|
||||
---
|
||||
|
||||
## What's included (and where)
|
||||
|
||||
### Project context
|
||||
|
||||
Run **`shadcn info --json`** (here: `cd web/default && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths.
|
||||
|
||||
### CLI commands
|
||||
|
||||
Full command reference (vendored): [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
|
||||
|
||||
### Theming and customization
|
||||
|
||||
Vendored: [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md). Live docs: [Theming](https://ui.shadcn.com/docs/theming).
|
||||
|
||||
### Registry authoring
|
||||
|
||||
Not duplicated as a single file in the vendor tree; see [Registry](https://ui.shadcn.com/docs/registry) and `build` in [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md).
|
||||
|
||||
### MCP server
|
||||
|
||||
Vendored: [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md). Live docs: [MCP Server](https://ui.shadcn.com/docs/mcp).
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Project detection** — Applies when `components.json` exists (here: `web/default/components.json`).
|
||||
2. **Context injection** — Use `shadcn info --json` as ground truth for imports and APIs.
|
||||
3. **Pattern enforcement** — Follow rules in [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) and [`vendor/shadcn/rules/`](./vendor/shadcn/rules/).
|
||||
4. **Component discovery** — `shadcn docs`, `shadcn search`, MCP, or registries — see vendored SKILL + MCP doc.
|
||||
|
||||
---
|
||||
|
||||
## Learn more (web)
|
||||
|
||||
- [CLI](https://ui.shadcn.com/docs/cli) — complements [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md)
|
||||
- [Theming](https://ui.shadcn.com/docs/theming)
|
||||
- [Registry](https://ui.shadcn.com/docs/registry)
|
||||
- [skills.sh](https://skills.sh)
|
||||
|
||||
---
|
||||
|
||||
## Vendored upstream bundle (deep rules)
|
||||
|
||||
Snapshot from [shadcn-ui/ui `skills/shadcn`](https://github.com/shadcn-ui/ui/tree/main/skills/shadcn); revision note in [`vendor/shadcn/UPSTREAM.txt`](./vendor/shadcn/UPSTREAM.txt).
|
||||
|
||||
| Doc | Path |
|
||||
| --- | --- |
|
||||
| Full official skill body | [`vendor/shadcn/SKILL.md`](./vendor/shadcn/SKILL.md) |
|
||||
| CLI reference | [`vendor/shadcn/cli.md`](./vendor/shadcn/cli.md) |
|
||||
| Theming / customization | [`vendor/shadcn/customization.md`](./vendor/shadcn/customization.md) |
|
||||
| MCP | [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md) |
|
||||
| Forms | [`vendor/shadcn/rules/forms.md`](./vendor/shadcn/rules/forms.md) |
|
||||
| Composition | [`vendor/shadcn/rules/composition.md`](./vendor/shadcn/rules/composition.md) |
|
||||
| Icons | [`vendor/shadcn/rules/icons.md`](./vendor/shadcn/rules/icons.md) |
|
||||
| Styling | [`vendor/shadcn/rules/styling.md`](./vendor/shadcn/rules/styling.md) |
|
||||
| Base vs Radix | [`vendor/shadcn/rules/base-vs-radix.md`](./vendor/shadcn/rules/base-vs-radix.md) |
|
||||
|
||||
**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web/default`, Bun). Read **`vendor/shadcn/SKILL.md`** for the complete upstream workflow, patterns, and CLI quick reference. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup.
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: shadcn
|
||||
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||
|
||||
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
## Current Project Context
|
||||
|
||||
```json
|
||||
!`npx shadcn@latest info --json`
|
||||
```
|
||||
|
||||
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||
|
||||
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||
|
||||
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||
|
||||
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||
|
||||
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||
|
||||
### Component Structure → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||
|
||||
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **Use `Badge`** instead of custom styled spans.
|
||||
|
||||
### Icons → [icons.md](./rules/icons.md)
|
||||
|
||||
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||
|
||||
```tsx
|
||||
// Form layout: FieldGroup + Field, not div + Label.
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||
<Field data-invalid>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input aria-invalid />
|
||||
<FieldDescription>Invalid email.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Icons in buttons: data-icon, no sizing classes.
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
// Spacing: gap-*, not space-y-*.
|
||||
<div className="flex flex-col gap-4"> // correct
|
||||
<div className="space-y-4"> // wrong
|
||||
|
||||
// Equal dimensions: size-*, not w-* h-*.
|
||||
<Avatar className="size-10"> // correct
|
||||
<Avatar className="w-10 h-10"> // wrong
|
||||
|
||||
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||
```
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Button/action | `Button` with appropriate variant |
|
||||
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||
| Command palette | `Command` inside `Dialog` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
| Empty states | `Empty` |
|
||||
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||
|
||||
## Key Fields
|
||||
|
||||
The injected project context contains these key fields:
|
||||
|
||||
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
## Component Docs, Examples, and Usage
|
||||
|
||||
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs button dialog select
|
||||
```
|
||||
|
||||
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||
3. **Find components** — `npx shadcn@latest search`.
|
||||
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
## Updating Components
|
||||
|
||||
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||
|
||||
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||
3. Decide per file based on the diff:
|
||||
- No local changes → safe to overwrite.
|
||||
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova
|
||||
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||
|
||||
# Create a monorepo project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only font
|
||||
npx shadcn@latest apply a2r6bw --only theme,font
|
||||
|
||||
# Inspect preset codes and project preset state.
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
npx shadcn@latest preset resolve
|
||||
npx shadcn@latest preset resolve --json
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
```
|
||||
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
@@ -0,0 +1,3 @@
|
||||
Source: https://github.com/shadcn-ui/ui/tree/56161142f1b83f612462772d18883807b5f0d601/skills/shadcn
|
||||
Branch: main
|
||||
Fetched: 2026-04-29
|
||||
@@ -0,0 +1,276 @@
|
||||
# shadcn CLI Reference
|
||||
|
||||
Configuration is read from `components.json`.
|
||||
|
||||
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `init` — Initialize or create a project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init [components...] [options]
|
||||
```
|
||||
|
||||
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--name <name>` | `-n` | Name for new project | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--rtl` | | Enable RTL support | — |
|
||||
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `apply` — Apply a preset to an existing project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply [preset] [options]
|
||||
```
|
||||
|
||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
|
||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--all` | `-a` | Add all available components | `false` |
|
||||
| `--path <path>` | `-p` | Target path for the component | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
|
||||
#### Dry-Run Mode
|
||||
|
||||
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||
|
||||
```bash
|
||||
# Preview all changes.
|
||||
npx shadcn@latest add button --dry-run
|
||||
|
||||
# Show diffs for all files (top 5).
|
||||
npx shadcn@latest add button --diff
|
||||
|
||||
# Show the diff for a specific file.
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
|
||||
# Show contents for all files (top 5).
|
||||
npx shadcn@latest add button --view
|
||||
|
||||
# Show the full content of a specific file.
|
||||
npx shadcn@latest add button --view button.tsx
|
||||
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
|
||||
**When to use dry-run:**
|
||||
|
||||
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||
- When the user wants to inspect component source code without installing — use `--view`.
|
||||
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||
|
||||
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||
|
||||
#### Smart Merge from Upstream
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search <registries...> [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ---------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs <components...> [options]
|
||||
```
|
||||
|
||||
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||
|
||||
Example output for `npx shadcn@latest docs input button`:
|
||||
|
||||
```
|
||||
base radix
|
||||
|
||||
input
|
||||
docs https://ui.shadcn.com/docs/components/radix/input
|
||||
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||
|
||||
button
|
||||
docs https://ui.shadcn.com/docs/components/radix/button
|
||||
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||
```
|
||||
|
||||
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||
|
||||
### `diff` — Check for updates
|
||||
|
||||
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||
|
||||
### `info` — Project information
|
||||
|
||||
```bash
|
||||
npx shadcn@latest info [options]
|
||||
```
|
||||
|
||||
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------- | ----- | ----------------- | ------- |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
**Project Info fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||
|
||||
**Components.json fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||
| `rsc` | `boolean` | RSC flag from config |
|
||||
| `tsx` | `boolean` | TypeScript flag |
|
||||
| `tailwind.config` | `string` | Tailwind config path |
|
||||
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||
| `registries` | `object` | Configured custom registries |
|
||||
|
||||
**Links fields:**
|
||||
|
||||
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||
|
||||
### `build` — Build a custom registry
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build [registry] [options]
|
||||
```
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Value | Framework | Monorepo support |
|
||||
| -------------- | -------------- | ---------------- |
|
||||
| `next` | Next.js | Yes |
|
||||
| `vite` | Vite | Yes |
|
||||
| `start` | TanStack Start | Yes |
|
||||
| `react-router` | React Router | Yes |
|
||||
| `astro` | Astro | Yes |
|
||||
| `laravel` | Laravel | No |
|
||||
|
||||
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset nova` or `--preset lyra`
|
||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
|
||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Customization & Theming
|
||||
|
||||
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||
|
||||
## Contents
|
||||
|
||||
- How it works (CSS variables → Tailwind utilities → components)
|
||||
- Color variables and OKLCH format
|
||||
- Dark mode setup
|
||||
- Changing the theme (presets, CSS variables)
|
||||
- Adding custom colors (Tailwind v3 and v4)
|
||||
- Border radius
|
||||
- Customizing components (variants, className, wrappers)
|
||||
- Checking for updates
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||
|
||||
---
|
||||
|
||||
## Color Variables
|
||||
|
||||
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `--background` / `--foreground` | Page background and default text |
|
||||
| `--card` / `--card-foreground` | Card surfaces |
|
||||
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||
| `--border` | Default border color |
|
||||
| `--input` | Form input borders |
|
||||
| `--ring` | Focus ring color |
|
||||
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||
| `--sidebar-*` | Sidebar-specific colors |
|
||||
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||
|
||||
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changing the Theme
|
||||
|
||||
```bash
|
||||
# Apply a preset code from ui.shadcn.com.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
|
||||
# Positional shorthand also works.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Switch to a named preset and overwrite existing components.
|
||||
npx shadcn@latest apply --preset nova
|
||||
|
||||
# Preserve existing components instead.
|
||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Colors
|
||||
|
||||
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||
|
||||
```css
|
||||
/* 1. Define in the global CSS file. */
|
||||
:root {
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--warning-foreground: oklch(0.28 0.07 46);
|
||||
}
|
||||
.dark {
|
||||
--warning: oklch(0.41 0.11 46);
|
||||
--warning-foreground: oklch(0.99 0.02 95);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||
@theme inline {
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
}
|
||||
```
|
||||
|
||||
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||
|
||||
```js
|
||||
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||
"warning-foreground":
|
||||
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 3. Use in components.
|
||||
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Components
|
||||
|
||||
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||
|
||||
Prefer these approaches in order:
|
||||
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
Click
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="mx-auto max-w-md">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Edit the component source to add a variant via `cva`:
|
||||
|
||||
```tsx
|
||||
// components/ui/button.tsx
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
```
|
||||
|
||||
### 4. Wrapper components
|
||||
|
||||
Compose shadcn/ui primitives into higher-level components:
|
||||
|
||||
```tsx
|
||||
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --diff
|
||||
```
|
||||
|
||||
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --dry-run # see all affected files
|
||||
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||
```
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||
@@ -0,0 +1,94 @@
|
||||
# shadcn MCP Server
|
||||
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
shadcn mcp # start the MCP server (stdio)
|
||||
shadcn mcp init # write config for your editor
|
||||
```
|
||||
|
||||
Editor config files:
|
||||
|
||||
| Editor | Config file |
|
||||
|--------|------------|
|
||||
| Claude Code | `.mcp.json` |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| VS Code | `.vscode/mcp.json` |
|
||||
| OpenCode | `opencode.json` |
|
||||
| Codex | `~/.codex/config.toml` (manual) |
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||
|
||||
### `shadcn:get_project_registries`
|
||||
|
||||
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||
|
||||
**Input:** none
|
||||
|
||||
### `shadcn:list_items_in_registries`
|
||||
|
||||
Lists all items from one or more registries.
|
||||
|
||||
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
### `shadcn:view_items_in_registries`
|
||||
|
||||
View item details including full file contents.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
Find usage examples and demos with source code.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
|
||||
### `shadcn:get_add_command_for_items`
|
||||
|
||||
Returns the CLI install command.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||
|
||||
### `shadcn:get_audit_checklist`
|
||||
|
||||
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||
|
||||
**Input:** none
|
||||
|
||||
---
|
||||
|
||||
## Configuring Registries
|
||||
|
||||
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json",
|
||||
"@private": {
|
||||
"url": "https://private.com/r/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Names must start with `@`.
|
||||
- URLs must contain `{name}`.
|
||||
- `${VAR}` references are resolved from environment variables.
|
||||
|
||||
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||
@@ -0,0 +1,306 @@
|
||||
# Base vs Radix
|
||||
|
||||
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||
|
||||
## Contents
|
||||
|
||||
- Composition: asChild vs render
|
||||
- Button / trigger as non-button element
|
||||
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||
- ToggleGroup (type vs multiple)
|
||||
- Slider (scalar vs array)
|
||||
- Accordion (type and defaultValue)
|
||||
|
||||
---
|
||||
|
||||
## Composition: asChild (radix) vs render (base)
|
||||
|
||||
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger>
|
||||
<div>
|
||||
<Button>Open</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open</Button>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||
```
|
||||
|
||||
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||
|
||||
---
|
||||
|
||||
## Button / trigger as non-button element (base only)
|
||||
|
||||
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||
|
||||
**Incorrect (base):** missing `nativeButton={false}`.
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||
Read the docs
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Button asChild>
|
||||
<a href="/docs">Read the docs</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Same for triggers whose `render` is not a `Button`:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||
Pick date
|
||||
</PopoverTrigger>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select
|
||||
|
||||
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
const items = [
|
||||
{ label: "Select a fruit", value: null },
|
||||
{ label: "Apple", value: "apple" },
|
||||
{ label: "Banana", value: "banana" },
|
||||
]
|
||||
|
||||
<Select items={items}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||
|
||||
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||
|
||||
// radix.
|
||||
<SelectContent position="popper">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select — multiple selection and object values (base only)
|
||||
|
||||
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||
|
||||
**Correct (base — multiple selection):**
|
||||
|
||||
```tsx
|
||||
<Select items={items} multiple defaultValue={[]}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base — object values):**
|
||||
|
||||
```tsx
|
||||
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>{(value) => value.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ToggleGroup
|
||||
|
||||
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<ToggleGroup type="single" defaultValue="daily">
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
// Single (no prop needed), defaultValue is always an array.
|
||||
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup multiple>
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
// Single, defaultValue is a string.
|
||||
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup type="multiple">
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Controlled single value:**
|
||||
|
||||
```tsx
|
||||
// base — wrap/unwrap arrays.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||
|
||||
// radix — plain string.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slider
|
||||
|
||||
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={50} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||
|
||||
// radix.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={setValue} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accordion
|
||||
|
||||
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion defaultValue={["item-1"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
// Multi-select.
|
||||
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
<AccordionItem value="item-2">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
# Component Composition
|
||||
|
||||
## Contents
|
||||
|
||||
- Items always inside their Group component
|
||||
- Callouts use Alert
|
||||
- Empty states use Empty component
|
||||
- Toast notifications use sonner
|
||||
- Choosing between overlay components
|
||||
- Dialog, Sheet, and Drawer always need a Title
|
||||
- Card structure
|
||||
- Button has no isPending or isLoading prop
|
||||
- TabsTrigger must be inside TabsList
|
||||
- Avatar always needs AvatarFallback
|
||||
- Use Separator instead of raw hr or border divs
|
||||
- Use Skeleton for loading placeholders
|
||||
- Use Badge instead of custom styled spans
|
||||
|
||||
---
|
||||
|
||||
## Items always inside their Group component
|
||||
|
||||
Never render items directly inside the content container.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
This applies to all group-based components:
|
||||
|
||||
| Item | Group |
|
||||
|------|-------|
|
||||
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||
| `MenubarItem` | `MenubarGroup` |
|
||||
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||
| `CommandItem` | `CommandGroup` |
|
||||
|
||||
---
|
||||
|
||||
## Callouts use Alert
|
||||
|
||||
```tsx
|
||||
<Alert>
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>Something needs attention.</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empty states use Empty component
|
||||
|
||||
```tsx
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||
<EmptyTitle>No projects yet</EmptyTitle>
|
||||
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button>Create Project</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toast notifications use sonner
|
||||
|
||||
```tsx
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved.")
|
||||
toast.error("Something went wrong.")
|
||||
toast("File deleted.", {
|
||||
action: { label: "Undo", onClick: () => undoDelete() },
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing between overlay components
|
||||
|
||||
| Use case | Component |
|
||||
|----------|-----------|
|
||||
| Focused task that requires input | `Dialog` |
|
||||
| Destructive action confirmation | `AlertDialog` |
|
||||
| Side panel with details or filters | `Sheet` |
|
||||
| Mobile-first bottom panel | `Drawer` |
|
||||
| Quick info on hover | `HoverCard` |
|
||||
| Small contextual content on click | `Popover` |
|
||||
|
||||
---
|
||||
|
||||
## Dialog, Sheet, and Drawer always need a Title
|
||||
|
||||
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Update your profile.</DialogDescription>
|
||||
</DialogHeader>
|
||||
...
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card structure
|
||||
|
||||
Use full composition — don't dump everything into `CardContent`:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>Manage your team.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>...</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Invite</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button has no isPending or isLoading prop
|
||||
|
||||
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||
|
||||
```tsx
|
||||
<Button disabled>
|
||||
<Spinner data-icon="inline-start" />
|
||||
Saving...
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TabsTrigger must be inside TabsList
|
||||
|
||||
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar always needs AvatarFallback
|
||||
|
||||
Always include `AvatarFallback` for when the image fails to load:
|
||||
|
||||
```tsx
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatar.png" alt="User" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use existing components instead of custom markup
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 choices) use ToggleGroup
|
||||
- FieldSet + FieldLegend for grouping related fields
|
||||
- Field validation and disabled states
|
||||
|
||||
---
|
||||
|
||||
## Forms use FieldGroup + Field
|
||||
|
||||
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||
|
||||
```tsx
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Input id="password" type="password" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
```
|
||||
|
||||
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||
|
||||
**Choosing form controls:**
|
||||
|
||||
- Simple text input → `Input`
|
||||
- Dropdown with predefined options → `Select`
|
||||
- Searchable dropdown → `Combobox`
|
||||
- Native HTML select (no JS) → `native-select`
|
||||
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||
- Single choice from few options → `RadioGroup`
|
||||
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||
- OTP/verification code → `InputOTP`
|
||||
- Multi-line text → `Textarea`
|
||||
|
||||
---
|
||||
|
||||
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
|
||||
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<InputGroup>
|
||||
<Input placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
|
||||
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<Input placeholder="Search..." className="pr-10" />
|
||||
<Button className="absolute right-0 top-0" size="icon">
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
<InputGroupAddon>
|
||||
<Button size="icon">
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option sets (2–7 choices) use ToggleGroup
|
||||
|
||||
Don't manually loop `Button` components with active state.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const [selected, setSelected] = useState("daily")
|
||||
|
||||
<div className="flex gap-2">
|
||||
{["daily", "weekly", "monthly"].map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant={selected === option ? "default" : "outline"}
|
||||
onClick={() => setSelected(option)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
<ToggleGroup spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
Combine with `Field` for labelled toggle groups:
|
||||
|
||||
```tsx
|
||||
<Field orientation="horizontal">
|
||||
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
```
|
||||
|
||||
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||
|
||||
---
|
||||
|
||||
## FieldSet + FieldLegend for grouping related fields
|
||||
|
||||
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||
|
||||
```tsx
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||
<FieldDescription>Select all that apply.</FieldDescription>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dark" />
|
||||
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field validation and disabled states
|
||||
|
||||
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||
|
||||
```tsx
|
||||
// Invalid.
|
||||
<Field data-invalid>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" aria-invalid />
|
||||
<FieldDescription>Invalid email address.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Disabled.
|
||||
<Field data-disabled>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" disabled />
|
||||
</Field>
|
||||
```
|
||||
|
||||
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Icons
|
||||
|
||||
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||
|
||||
---
|
||||
|
||||
## Icons in Button use data-icon attribute
|
||||
|
||||
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="mr-2 size-4" />
|
||||
Search
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start"/>
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<Button>
|
||||
Next
|
||||
<ArrowRightIcon data-icon="inline-end"/>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No sizing classes on icons inside components
|
||||
|
||||
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pass icons as component objects, not string keys
|
||||
|
||||
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const iconMap = {
|
||||
check: CheckIcon,
|
||||
alert: AlertIcon,
|
||||
}
|
||||
|
||||
function StatusBadge({ icon }: { icon: string }) {
|
||||
const Icon = iconMap[icon]
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon="check" />
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon={CheckIcon} />
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# Styling & Customization
|
||||
|
||||
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||
|
||||
## Contents
|
||||
|
||||
- Semantic colors
|
||||
- Built-in variants first
|
||||
- className for layout only
|
||||
- No space-x-* / space-y-*
|
||||
- Prefer size-* over w-* h-* when equal
|
||||
- Prefer truncate shorthand
|
||||
- No manual dark: color overrides
|
||||
- Use cn() for conditional classes
|
||||
- No manual z-index on overlay components
|
||||
|
||||
---
|
||||
|
||||
## Semantic colors
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-blue-500 text-white">
|
||||
<p className="text-gray-600">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-primary text-primary-foreground">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No raw color values for status/state indicators
|
||||
|
||||
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<span className="text-emerald-600">+20.1%</span>
|
||||
<span className="text-green-500">Active</span>
|
||||
<span className="text-red-600">-3.2%</span>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="secondary">+20.1%</Badge>
|
||||
<Badge>Active</Badge>
|
||||
<span className="text-destructive">-3.2%</span>
|
||||
```
|
||||
|
||||
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## Built-in variants first
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button variant="outline">Click me</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## className for layout only
|
||||
|
||||
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
To customize a component's appearance, prefer these approaches in order:
|
||||
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## No space-x-* / space-y-*
|
||||
|
||||
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input />
|
||||
<Input />
|
||||
<Button>Submit</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prefer size-* over w-* h-* when equal
|
||||
|
||||
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||
|
||||
---
|
||||
|
||||
## Prefer truncate shorthand
|
||||
|
||||
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
|
||||
---
|
||||
|
||||
## No manual dark: color overrides
|
||||
|
||||
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||
|
||||
---
|
||||
|
||||
## Use cn() for conditional classes
|
||||
|
||||
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No manual z-index on overlay components
|
||||
|
||||
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish Docker image (Multi Registries, native amd64+arm64)
|
||||
name: Publish Docker image (Multi-arch)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build_single_arch:
|
||||
name: Build & push (${{ matrix.arch }}) [native]
|
||||
name: Build & push (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -26,6 +26,8 @@ jobs:
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -34,58 +36,46 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@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 in the repository"
|
||||
echo "::error::Tag '$TAG' does not exist"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
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
|
||||
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "${TAG}" > VERSION
|
||||
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@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@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
calciumion/new-api
|
||||
# ghcr.io/${{ env.GHCR_REPOSITORY }}
|
||||
images: calciumion/new-api
|
||||
|
||||
- name: Build & push single-arch (to both registries)
|
||||
- name: Build & push
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -93,8 +83,6 @@ 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
|
||||
@@ -102,81 +90,52 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
||||
uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Sign image with cosign
|
||||
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Output digest
|
||||
- name: Image summary
|
||||
run: |
|
||||
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
create_manifests:
|
||||
name: Create multi-arch manifests (Docker Hub)
|
||||
name: Create multi-arch manifests
|
||||
needs: [build_single_arch]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- 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: Set version
|
||||
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & push manifest (Docker Hub - version)
|
||||
- name: Create & push manifest (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 (Docker Hub - latest)
|
||||
- name: Create & push manifest (latest)
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t calciumion/new-api:latest \
|
||||
calciumion/new-api:latest-amd64 \
|
||||
calciumion/new-api:latest-arm64
|
||||
|
||||
- name: Output manifest digest
|
||||
- name: Manifest summary
|
||||
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
|
||||
@@ -29,14 +29,22 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
- name: Build Frontend (default)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
cd web/default
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
@@ -78,15 +86,23 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
- name: Build Frontend (default)
|
||||
env:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web
|
||||
cd web/default
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
@@ -126,14 +142,22 @@ jobs:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
- name: Build Frontend (default)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
cd web/default
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ..
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
|
||||
@@ -8,6 +8,9 @@ upload
|
||||
build
|
||||
*.db-journal
|
||||
logs
|
||||
web/default/dist
|
||||
web/classic/dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
one-api
|
||||
@@ -19,9 +22,9 @@ tiktoken_cache
|
||||
.gocache
|
||||
.gomodcache/
|
||||
.cache
|
||||
web/bun.lock
|
||||
plans
|
||||
.claude
|
||||
.cursor
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
|
||||
@@ -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 18, Vite, Semi Design UI (@douyinfe/semi-ui)
|
||||
- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
|
||||
- **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,8 +33,10 @@ 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/ — React frontend
|
||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||
web/ — Frontend themes container
|
||||
web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
|
||||
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
|
||||
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
@@ -43,13 +45,12 @@ web/ — React frontend
|
||||
- Library: `nicksnyder/go-i18n/v2`
|
||||
- Languages: en, zh
|
||||
|
||||
### Frontend (`web/src/i18n/`)
|
||||
### Frontend (`web/default/src/i18n/`)
|
||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||
- 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`
|
||||
- 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/`)
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -93,7 +94,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/` directory):
|
||||
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
|
||||
- `bun install` for dependency installation
|
||||
- `bun run dev` for development server
|
||||
- `bun run build` for production build
|
||||
|
||||
@@ -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 18, Vite, Semi Design UI (@douyinfe/semi-ui)
|
||||
- **Frontend**: React 19, TypeScript, Rsbuild, Base UI, Tailwind CSS
|
||||
- **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,8 +33,10 @@ 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/ — React frontend
|
||||
web/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||
web/ — Frontend themes container
|
||||
web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
|
||||
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
|
||||
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
@@ -43,13 +45,12 @@ web/ — React frontend
|
||||
- Library: `nicksnyder/go-i18n/v2`
|
||||
- Languages: en, zh
|
||||
|
||||
### Frontend (`web/src/i18n/`)
|
||||
### Frontend (`web/default/src/i18n/`)
|
||||
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
|
||||
- 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`
|
||||
- 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/`)
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -93,7 +94,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/` directory):
|
||||
Use `bun` as the preferred package manager and script runner for the frontend (`web/default/` directory):
|
||||
- `bun install` for dependency installation
|
||||
- `bun run dev` for development server
|
||||
- `bun run build` for production build
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/package.json .
|
||||
COPY web/bun.lock .
|
||||
COPY web/default/package.json .
|
||||
COPY web/default/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web .
|
||||
COPY ./web/default .
|
||||
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
|
||||
|
||||
@@ -22,7 +32,8 @@ ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /build/dist ./web/dist
|
||||
COPY --from=builder /build/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Backend-only build for frontend development
|
||||
# Skips frontend build, uses a placeholder for //go:embed web/dist
|
||||
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p web/default/dist web/classic/dist && \
|
||||
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/default/dist/index.html && \
|
||||
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/classic/dist/index.html
|
||||
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder /build/new-api /
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
@@ -0,0 +1,459 @@
|
||||
<div align="center">
|
||||
|
||||

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

|
||||

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

|
||||

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

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
# New API
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
//"os"
|
||||
//"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -17,6 +18,24 @@ 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
|
||||
|
||||
@@ -41,3 +41,29 @@ 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}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ func GetAllChannels(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
@@ -98,7 +99,7 @@ func GetAllChannels(c *gin.Context) {
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false)
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -131,12 +132,7 @@ func GetAllChannels(c *gin.Context) {
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
|
||||
err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
@@ -252,6 +248,7 @@ func SearchChannels(c *gin.Context) {
|
||||
statusParam := c.Query("status")
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
channelData := make([]*model.Channel, 0)
|
||||
if enableTagMode {
|
||||
@@ -265,14 +262,14 @@ func SearchChannels(c *gin.Context) {
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false)
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort)
|
||||
channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort, sortOptions)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DiscordResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type DiscordUser struct {
|
||||
UID string `json:"id"`
|
||||
ID string `json:"username"`
|
||||
Name string `json:"global_name"`
|
||||
}
|
||||
|
||||
func getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("无效的参数")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("client_id", system_setting.GetDiscordSettings().ClientId)
|
||||
values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
|
||||
formData := values.Encode()
|
||||
req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var discordResponse DiscordResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&discordResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if discordResponse.AccessToken == "" {
|
||||
common.SysError("Discord 获取 Token 失败,请检查设置!")
|
||||
return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
|
||||
res2, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
common.SysError("Discord 获取用户信息失败!请检查设置!")
|
||||
return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
|
||||
}
|
||||
|
||||
var discordUser DiscordUser
|
||||
err = json.NewDecoder(res2.Body).Decode(&discordUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if discordUser.UID == "" || discordUser.ID == "" {
|
||||
common.SysError("Discord 获取用户信息为空!请检查设置!")
|
||||
return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
|
||||
}
|
||||
return &discordUser, nil
|
||||
}
|
||||
|
||||
func DiscordOAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
state := c.Query("state")
|
||||
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "state is empty or not same",
|
||||
})
|
||||
return
|
||||
}
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
DiscordBind(c)
|
||||
return
|
||||
}
|
||||
if !system_setting.GetDiscordSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
discordUser, err := getDiscordUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
DiscordId: discordUser.UID,
|
||||
}
|
||||
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||
err := user.FillUserByDiscordId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
if discordUser.ID != "" {
|
||||
user.Username = discordUser.ID
|
||||
} else {
|
||||
user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
}
|
||||
if discordUser.Name != "" {
|
||||
user.DisplayName = discordUser.Name
|
||||
} else {
|
||||
user.DisplayName = "Discord User"
|
||||
}
|
||||
err := user.Insert(0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
func DiscordBind(c *gin.Context) {
|
||||
if !system_setting.GetDiscordSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Discord 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
discordUser, err := getDiscordUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
DiscordId: discordUser.UID,
|
||||
}
|
||||
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 Discord 账户已被绑定",
|
||||
})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
user.Id = id.(int)
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user.DiscordId = discordUser.UID
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GitHubOAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
type GitHubUser struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("无效的参数")
|
||||
}
|
||||
values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code}
|
||||
jsonData, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var oAuthResponse GitHubOAuthResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&oAuthResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err = http.NewRequest("GET", "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken))
|
||||
res2, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
var githubUser GitHubUser
|
||||
err = json.NewDecoder(res2.Body).Decode(&githubUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if githubUser.Login == "" {
|
||||
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
|
||||
}
|
||||
return &githubUser, nil
|
||||
}
|
||||
|
||||
func GitHubOAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
state := c.Query("state")
|
||||
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "state is empty or not same",
|
||||
})
|
||||
return
|
||||
}
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
GitHubBind(c)
|
||||
return
|
||||
}
|
||||
|
||||
if !common.GitHubOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
githubUser, err := getGitHubUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
GitHubId: githubUser.Login,
|
||||
}
|
||||
// IsGitHubIdAlreadyTaken is unscoped
|
||||
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||
// FillUserByGitHubId is scoped
|
||||
err := user.FillUserByGitHubId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// if user.Id == 0 , user has been deleted
|
||||
if user.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已注销",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
if githubUser.Name != "" {
|
||||
user.DisplayName = githubUser.Name
|
||||
} else {
|
||||
user.DisplayName = "GitHub User"
|
||||
}
|
||||
user.Email = githubUser.Email
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
affCode := session.Get("aff")
|
||||
inviterId := 0
|
||||
if affCode != nil {
|
||||
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||
}
|
||||
|
||||
if err := user.Insert(inviterId); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
func GitHubBind(c *gin.Context) {
|
||||
if !common.GitHubOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 GitHub 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
githubUser, err := getGitHubUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
GitHubId: githubUser.Login,
|
||||
}
|
||||
if model.IsGitHubIdAlreadyTaken(user.GitHubId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 GitHub 账户已被绑定",
|
||||
})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
// id := c.GetInt("id") // critical bug!
|
||||
user.Id = id.(int)
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user.GitHubId = githubUser.Login
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LinuxdoUser struct {
|
||||
Id int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
TrustLevel int `json:"trust_level"`
|
||||
Silenced bool `json:"silenced"`
|
||||
}
|
||||
|
||||
func LinuxDoBind(c *gin.Context) {
|
||||
if !common.LinuxDOOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Linux DO 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
LinuxDOId: strconv.Itoa(linuxdoUser.Id),
|
||||
}
|
||||
|
||||
if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 Linux DO 账户已被绑定",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
user.Id = id.(int)
|
||||
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
}
|
||||
|
||||
func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("invalid code")
|
||||
}
|
||||
|
||||
// Get access token using Basic auth
|
||||
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
|
||||
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||
|
||||
// Get redirect URI from request
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host)
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
|
||||
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", basicAuth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to connect to Linux DO server")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var tokenRes struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if tokenRes.AccessToken == "" {
|
||||
return nil, fmt.Errorf("failed to get access token: %s", tokenRes.Message)
|
||||
}
|
||||
|
||||
// Get user info
|
||||
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
|
||||
req, err = http.NewRequest("GET", userEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res2, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get user info from Linux DO")
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
|
||||
var linuxdoUser LinuxdoUser
|
||||
if err := json.NewDecoder(res2.Body).Decode(&linuxdoUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if linuxdoUser.Id == 0 {
|
||||
return nil, errors.New("invalid user info returned")
|
||||
}
|
||||
|
||||
return &linuxdoUser, nil
|
||||
}
|
||||
|
||||
func LinuxdoOAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
errorCode := c.Query("error")
|
||||
if errorCode != "" {
|
||||
errorDescription := c.Query("error_description")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errorDescription,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state := c.Query("state")
|
||||
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "state is empty or not same",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
LinuxDoBind(c)
|
||||
return
|
||||
}
|
||||
|
||||
if !common.LinuxDOOAuthEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 Linux DO 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := c.Query("code")
|
||||
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
LinuxDOId: strconv.Itoa(linuxdoUser.Id),
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) {
|
||||
err := user.FillUserByLinuxDOId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if user.Id == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已注销",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
|
||||
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
user.DisplayName = linuxdoUser.Name
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
|
||||
affCode := session.Get("aff")
|
||||
inviterId := 0
|
||||
if affCode != nil {
|
||||
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||
}
|
||||
|
||||
if err := user.Insert(inviterId); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ func GetStatus(c *gin.Context) {
|
||||
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type OidcResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type OidcUser struct {
|
||||
OpenID string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func getOidcUserInfoByCode(code string) (*OidcUser, error) {
|
||||
if code == "" {
|
||||
return nil, errors.New("无效的参数")
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("client_id", system_setting.GetOIDCSettings().ClientId)
|
||||
values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
|
||||
values.Set("code", code)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
|
||||
formData := values.Encode()
|
||||
req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var oidcResponse OidcResponse
|
||||
err = json.NewDecoder(res.Body).Decode(&oidcResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oidcResponse.AccessToken == "" {
|
||||
common.SysLog("OIDC 获取 Token 失败,请检查设置!")
|
||||
return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
|
||||
}
|
||||
|
||||
req, err = http.NewRequest("GET", system_setting.GetOIDCSettings().UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken)
|
||||
res2, err := client.Do(req)
|
||||
if err != nil {
|
||||
common.SysLog(err.Error())
|
||||
return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!")
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
if res2.StatusCode != http.StatusOK {
|
||||
common.SysLog("OIDC 获取用户信息失败!请检查设置!")
|
||||
return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
|
||||
}
|
||||
|
||||
var oidcUser OidcUser
|
||||
err = json.NewDecoder(res2.Body).Decode(&oidcUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oidcUser.OpenID == "" || oidcUser.Email == "" {
|
||||
common.SysLog("OIDC 获取用户信息为空!请检查设置!")
|
||||
return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
|
||||
}
|
||||
return &oidcUser, nil
|
||||
}
|
||||
|
||||
func OidcAuth(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
state := c.Query("state")
|
||||
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "state is empty or not same",
|
||||
})
|
||||
return
|
||||
}
|
||||
username := session.Get("username")
|
||||
if username != nil {
|
||||
OidcBind(c)
|
||||
return
|
||||
}
|
||||
if !system_setting.GetOIDCSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 OIDC 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
oidcUser, err := getOidcUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
OidcId: oidcUser.OpenID,
|
||||
}
|
||||
if model.IsOidcIdAlreadyTaken(user.OidcId) {
|
||||
err := user.FillUserByOidcId()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
user.Email = oidcUser.Email
|
||||
if oidcUser.PreferredUsername != "" {
|
||||
user.Username = oidcUser.PreferredUsername
|
||||
} else {
|
||||
user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
}
|
||||
if oidcUser.Name != "" {
|
||||
user.DisplayName = oidcUser.Name
|
||||
} else {
|
||||
user.DisplayName = "OIDC User"
|
||||
}
|
||||
err := user.Insert(0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员关闭了新用户注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "用户已被封禁",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
func OidcBind(c *gin.Context) {
|
||||
if !system_setting.GetOIDCSettings().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未开启通过 OIDC 登录以及注册",
|
||||
})
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
oidcUser, err := getOidcUserInfoByCode(code)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user := model.User{
|
||||
OidcId: oidcUser.OpenID,
|
||||
}
|
||||
if model.IsOidcIdAlreadyTaken(user.OidcId) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该 OIDC 账户已被绑定",
|
||||
})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
id := session.Get("id")
|
||||
// id := c.GetInt("id") // critical bug!
|
||||
user.Id = id.(int)
|
||||
err = user.FillUserById()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
user.OidcId = oidcUser.OpenID
|
||||
err = user.Update(false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "bind",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -198,6 +198,14 @@ func UpdateOption(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetPerfMetrics(c *gin.Context) {
|
||||
modelName := c.Query("model")
|
||||
if modelName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "model is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hours := 24
|
||||
if rawHours := c.Query("hours"); rawHours != "" {
|
||||
if parsed, err := strconv.Atoi(rawHours); err == nil {
|
||||
hours = parsed
|
||||
}
|
||||
}
|
||||
|
||||
result, err := perfmetrics.Query(perfmetrics.QueryParams{
|
||||
Model: modelName,
|
||||
Group: c.Query("group"),
|
||||
Hours: hours,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetRankings(c *gin.Context) {
|
||||
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
@@ -239,6 +240,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
|
||||
logger.LogInfo(c, retryLogStr)
|
||||
}
|
||||
if newAPIError != nil {
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, false, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
)
|
||||
|
||||
func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
|
||||
for channelId, taskIds := range taskChannelM {
|
||||
if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
|
||||
if len(taskIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
cacheGetChannel, err := model.CacheGetChannel(channelId)
|
||||
if err != nil {
|
||||
errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{
|
||||
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
|
||||
"status": "FAILURE",
|
||||
"progress": "100%",
|
||||
})
|
||||
if errUpdate != nil {
|
||||
common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
|
||||
}
|
||||
return fmt.Errorf("CacheGetChannel failed: %w", err)
|
||||
}
|
||||
adaptor := relay.GetTaskAdaptor(platform)
|
||||
if adaptor == nil {
|
||||
return fmt.Errorf("video adaptor not found")
|
||||
}
|
||||
info := &relaycommon.RelayInfo{}
|
||||
info.ChannelMeta = &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
|
||||
}
|
||||
info.ApiKey = cacheGetChannel.Key
|
||||
adaptor.Init(info)
|
||||
for _, taskId := range taskIds {
|
||||
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error {
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
proxy := channel.GetSetting().Proxy
|
||||
|
||||
task := taskM[taskId]
|
||||
if task == nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
|
||||
return fmt.Errorf("task %s not found", taskId)
|
||||
}
|
||||
key := channel.Key
|
||||
|
||||
privateData := task.PrivateData
|
||||
if privateData.Key != "" {
|
||||
key = privateData.Key
|
||||
}
|
||||
resp, err := adaptor.FetchTask(baseURL, key, map[string]any{
|
||||
"task_id": taskId,
|
||||
"action": task.Action,
|
||||
}, proxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err)
|
||||
}
|
||||
//if resp.StatusCode != http.StatusOK {
|
||||
//return fmt.Errorf("get Video Task status code: %d", resp.StatusCode)
|
||||
//}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||
|
||||
taskResult := &relaycommon.TaskInfo{}
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
taskResult.Url = t.FailReason
|
||||
taskResult.Progress = t.Progress
|
||||
taskResult.Reason = t.FailReason
|
||||
task.Data = t.Data
|
||||
} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
|
||||
return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
|
||||
} else {
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
//return fmt.Errorf("task %s status is empty", taskId)
|
||||
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
|
||||
}
|
||||
|
||||
// 记录原本的状态,防止重复退款
|
||||
shouldRefund := false
|
||||
quota := task.Quota
|
||||
preStatus := task.Status
|
||||
|
||||
task.Status = model.TaskStatus(taskResult.Status)
|
||||
switch taskResult.Status {
|
||||
case model.TaskStatusSubmitted:
|
||||
task.Progress = "10%"
|
||||
case model.TaskStatusQueued:
|
||||
task.Progress = "20%"
|
||||
case model.TaskStatusInProgress:
|
||||
task.Progress = "30%"
|
||||
if task.StartTime == 0 {
|
||||
task.StartTime = now
|
||||
}
|
||||
case model.TaskStatusSuccess:
|
||||
task.Progress = "100%"
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
|
||||
task.FailReason = taskResult.Url
|
||||
}
|
||||
|
||||
// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
|
||||
if taskResult.TotalTokens > 0 {
|
||||
// 获取模型名称
|
||||
var taskData map[string]interface{}
|
||||
if err := json.Unmarshal(task.Data, &taskData); err == nil {
|
||||
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
|
||||
// 获取模型价格和倍率
|
||||
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
|
||||
// 只有配置了倍率(非固定价格)时才按 token 重新计费
|
||||
if hasRatioSetting && modelRatio > 0 {
|
||||
// 获取用户和组的倍率信息
|
||||
group := task.Group
|
||||
if group == "" {
|
||||
user, err := model.GetUserById(task.UserId, false)
|
||||
if err == nil {
|
||||
group = user.Group
|
||||
}
|
||||
}
|
||||
if group != "" {
|
||||
groupRatio := ratio_setting.GetGroupRatio(group)
|
||||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
|
||||
|
||||
var finalGroupRatio float64
|
||||
if hasUserGroupRatio {
|
||||
finalGroupRatio = userGroupRatio
|
||||
} else {
|
||||
finalGroupRatio = groupRatio
|
||||
}
|
||||
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||
actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio)
|
||||
|
||||
// 计算差额
|
||||
preConsumedQuota := task.Quota
|
||||
quotaDelta := actualQuota - preConsumedQuota
|
||||
|
||||
if quotaDelta > 0 {
|
||||
// 需要补扣费
|
||||
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||
task.TaskID,
|
||||
logger.LogQuota(quotaDelta),
|
||||
logger.LogQuota(actualQuota),
|
||||
logger.LogQuota(preConsumedQuota),
|
||||
taskResult.TotalTokens,
|
||||
))
|
||||
if err := model.DecreaseUserQuota(task.UserId, quotaDelta, false); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error()))
|
||||
} else {
|
||||
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||||
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||||
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||
|
||||
// 记录消费日志
|
||||
logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s",
|
||||
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta))
|
||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||
}
|
||||
} else if quotaDelta < 0 {
|
||||
// 需要退还多扣的费用
|
||||
refundQuota := -quotaDelta
|
||||
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)",
|
||||
task.TaskID,
|
||||
logger.LogQuota(refundQuota),
|
||||
logger.LogQuota(actualQuota),
|
||||
logger.LogQuota(preConsumedQuota),
|
||||
taskResult.TotalTokens,
|
||||
))
|
||||
if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error()))
|
||||
} else {
|
||||
task.Quota = actualQuota // 更新任务记录的实际扣费额度
|
||||
|
||||
// 记录退款日志
|
||||
logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s",
|
||||
modelRatio, finalGroupRatio, taskResult.TotalTokens,
|
||||
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota))
|
||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||
}
|
||||
} else {
|
||||
// quotaDelta == 0, 预扣费刚好准确
|
||||
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)",
|
||||
task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case model.TaskStatusFailure:
|
||||
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
|
||||
task.Status = model.TaskStatusFailure
|
||||
task.Progress = "100%"
|
||||
if task.FinishTime == 0 {
|
||||
task.FinishTime = now
|
||||
}
|
||||
task.FailReason = taskResult.Reason
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
|
||||
taskResult.Progress = "100%"
|
||||
if quota != 0 {
|
||||
if preStatus != model.TaskStatusFailure {
|
||||
shouldRefund = true
|
||||
} else {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
|
||||
}
|
||||
if taskResult.Progress != "" {
|
||||
task.Progress = taskResult.Progress
|
||||
}
|
||||
if err := task.Update(); err != nil {
|
||||
common.SysLog("UpdateVideoTask task error: " + err.Error())
|
||||
shouldRefund = false
|
||||
}
|
||||
|
||||
if shouldRefund {
|
||||
// 任务失败且之前状态不是失败才退还额度,防止重复退还
|
||||
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
|
||||
logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
|
||||
}
|
||||
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
|
||||
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactVideoResponseBody(body []byte) []byte {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return body
|
||||
}
|
||||
resp, _ := m["response"].(map[string]any)
|
||||
if resp != nil {
|
||||
delete(resp, "bytesBase64Encoded")
|
||||
if v, ok := resp["video"].(string); ok {
|
||||
resp["video"] = truncateBase64(v)
|
||||
}
|
||||
if vs, ok := resp["videos"].([]any); ok {
|
||||
for i := range vs {
|
||||
if vm, ok := vs[i].(map[string]any); ok {
|
||||
delete(vm, "bytesBase64Encoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func truncateBase64(s string) string {
|
||||
const maxKeep = 256
|
||||
if len(s) <= maxKeep {
|
||||
return s
|
||||
}
|
||||
return s[:maxKeep] + "..."
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Frontend Development - Backend built from local source
|
||||
#
|
||||
# Usage:
|
||||
# 1. docker compose -f docker-compose.dev.yml up -d
|
||||
# 2. cd web && bun install && bun run dev
|
||||
# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000)
|
||||
#
|
||||
# Rebuild backend after Go code changes:
|
||||
# docker compose -f docker-compose.dev.yml up -d --build new-api
|
||||
#
|
||||
# Stop:
|
||||
# docker compose -f docker-compose.dev.yml down
|
||||
#
|
||||
# Reset data:
|
||||
# docker compose -f docker-compose.dev.yml down -v
|
||||
|
||||
services:
|
||||
new-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
image: new-api-dev:local
|
||||
container_name: new-api-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- dev_data:/data
|
||||
environment:
|
||||
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api
|
||||
- REDIS_CONN_STRING=redis://redis
|
||||
- TZ=Asia/Shanghai
|
||||
- BATCH_UPDATE_ENABLED=true
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- dev-network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: new-api-dev-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: new-api-dev-pg
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_PASSWORD: 123456
|
||||
POSTGRES_DB: new-api
|
||||
volumes:
|
||||
- dev_pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- dev-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U root -d new-api"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
dev_data:
|
||||
dev_pg_data:
|
||||
|
||||
networks:
|
||||
dev-network:
|
||||
driver: bridge
|
||||
@@ -279,8 +279,8 @@ type Message struct {
|
||||
Content any `json:"content"`
|
||||
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,6 +431,16 @@ 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
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/QuantumNous/new-api/relay"
|
||||
"github.com/QuantumNous/new-api/router"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -34,12 +35,18 @@ import (
|
||||
_ "net/http/pprof"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
//go:embed web/default/dist
|
||||
var buildFS embed.FS
|
||||
|
||||
//go:embed web/dist/index.html
|
||||
//go:embed web/default/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()
|
||||
|
||||
@@ -183,7 +190,12 @@ func main() {
|
||||
InjectGoogleAnalytics()
|
||||
|
||||
// 设置路由
|
||||
router.SetRouter(server, buildFS, indexPage)
|
||||
router.SetRouter(server, router.ThemeAssets{
|
||||
DefaultBuildFS: buildFS,
|
||||
DefaultIndexPage: indexPage,
|
||||
ClassicBuildFS: classicBuildFS,
|
||||
ClassicIndexPage: classicIndexPage,
|
||||
})
|
||||
var port = os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = strconv.Itoa(*common.Port)
|
||||
@@ -213,8 +225,10 @@ func InjectUmamiAnalytics() {
|
||||
analyticsInjectBuilder.WriteString("\"></script>")
|
||||
}
|
||||
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
|
||||
analyticsInject := analyticsInjectBuilder.String()
|
||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
|
||||
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||
placeholder := []byte("<!--umami-->\n")
|
||||
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||
}
|
||||
|
||||
func InjectGoogleAnalytics() {
|
||||
@@ -235,8 +249,10 @@ func InjectGoogleAnalytics() {
|
||||
analyticsInjectBuilder.WriteString("</script>")
|
||||
}
|
||||
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
|
||||
analyticsInject := analyticsInjectBuilder.String()
|
||||
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
|
||||
analyticsInject := []byte(analyticsInjectBuilder.String())
|
||||
placeholder := []byte("<!--Google Analytics-->\n")
|
||||
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
|
||||
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
|
||||
}
|
||||
|
||||
func InitResources() error {
|
||||
@@ -291,6 +307,8 @@ func InitResources() error {
|
||||
return err
|
||||
}
|
||||
|
||||
perfmetrics.Init()
|
||||
|
||||
// 启动系统监控
|
||||
common.StartSystemMonitor()
|
||||
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
FRONTEND_DIR = ./web
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
|
||||
.PHONY: all build-frontend start-backend
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||
|
||||
all: build-frontend start-backend
|
||||
all: build-all-frontends start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building frontend..."
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
@@ -67,6 +68,66 @@ type ChannelInfo struct {
|
||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
||||
}
|
||||
|
||||
type ChannelSortOptions struct {
|
||||
SortBy string
|
||||
SortOrder string
|
||||
IDSort bool
|
||||
}
|
||||
|
||||
var channelSortColumns = map[string]string{
|
||||
"id": "id",
|
||||
"name": "name",
|
||||
"priority": "priority",
|
||||
"balance": "balance",
|
||||
"response_time": "response_time",
|
||||
"test_time": "test_time",
|
||||
}
|
||||
|
||||
func NewChannelSortOptions(sortBy string, sortOrder string, idSort bool) ChannelSortOptions {
|
||||
normalizedSortBy := strings.ToLower(strings.TrimSpace(sortBy))
|
||||
normalizedSortOrder := strings.ToLower(strings.TrimSpace(sortOrder))
|
||||
if _, ok := channelSortColumns[normalizedSortBy]; !ok {
|
||||
normalizedSortBy = ""
|
||||
normalizedSortOrder = ""
|
||||
} else if normalizedSortOrder != "asc" {
|
||||
normalizedSortOrder = "desc"
|
||||
}
|
||||
|
||||
return ChannelSortOptions{
|
||||
SortBy: normalizedSortBy,
|
||||
SortOrder: normalizedSortOrder,
|
||||
IDSort: idSort,
|
||||
}
|
||||
}
|
||||
|
||||
func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB {
|
||||
if columnName, ok := channelSortColumns[options.SortBy]; ok {
|
||||
return query.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{Name: columnName},
|
||||
Desc: options.SortOrder != "asc",
|
||||
})
|
||||
}
|
||||
if options.IDSort {
|
||||
return query.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{Name: "id"},
|
||||
Desc: true,
|
||||
})
|
||||
}
|
||||
return query.Order(clause.OrderByColumn{
|
||||
Column: clause.Column{Name: "priority"},
|
||||
Desc: true,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) ChannelSortOptions {
|
||||
if len(sortOptions) == 0 {
|
||||
return NewChannelSortOptions("", "", idSort)
|
||||
}
|
||||
options := sortOptions[0]
|
||||
options.IDSort = options.IDSort || idSort
|
||||
return options
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (c ChannelInfo) Value() (driver.Value, error) {
|
||||
return common.Marshal(&c)
|
||||
@@ -260,28 +321,22 @@ func (channel *Channel) SaveWithoutKey() error {
|
||||
return DB.Omit("key").Save(channel).Error
|
||||
}
|
||||
|
||||
func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) {
|
||||
func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool, sortOptions ...ChannelSortOptions) ([]*Channel, error) {
|
||||
var channels []*Channel
|
||||
var err error
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
order := resolveChannelSortOptions(idSort, sortOptions)
|
||||
if selectAll {
|
||||
err = DB.Order(order).Find(&channels).Error
|
||||
err = order.Apply(DB).Find(&channels).Error
|
||||
} else {
|
||||
err = DB.Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error
|
||||
err = order.Apply(DB).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error
|
||||
}
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) {
|
||||
func GetChannelsByTag(tag string, idSort bool, selectAll bool, sortOptions ...ChannelSortOptions) ([]*Channel, error) {
|
||||
var channels []*Channel
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
query := DB.Where("tag = ?", tag).Order(order)
|
||||
order := resolveChannelSortOptions(idSort, sortOptions)
|
||||
query := order.Apply(DB.Where("tag = ?", tag))
|
||||
if !selectAll {
|
||||
query = query.Omit("key")
|
||||
}
|
||||
@@ -289,7 +344,7 @@ func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, erro
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func SearchChannels(keyword string, group string, model string, idSort bool) ([]*Channel, error) {
|
||||
func SearchChannels(keyword string, group string, model string, idSort bool, sortOptions ...ChannelSortOptions) ([]*Channel, error) {
|
||||
var channels []*Channel
|
||||
modelsCol := "`models`"
|
||||
|
||||
@@ -304,10 +359,7 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
|
||||
baseURLCol = `"base_url"`
|
||||
}
|
||||
|
||||
order := "priority desc"
|
||||
if idSort {
|
||||
order = "id desc"
|
||||
}
|
||||
order := resolveChannelSortOptions(idSort, sortOptions)
|
||||
|
||||
// 构造基础查询
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
@@ -331,7 +383,7 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
err := baseQuery.Where(whereClause, args...).Order(order).Find(&channels).Error
|
||||
err := order.Apply(baseQuery.Where(whereClause, args...)).Find(&channels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ func migrateDB() error {
|
||||
&SubscriptionPreConsumeRecord{},
|
||||
&CustomOAuthProvider{},
|
||||
&UserOAuthBinding{},
|
||||
&PerfMetric{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -328,6 +329,7 @@ func migrateDBFast() error {
|
||||
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
{&PerfMetric{}, "PerfMetric"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
@@ -581,6 +581,8 @@ func handleConfigUpdate(key, value string) bool {
|
||||
} else if configName == "billing_setting" {
|
||||
InvalidatePricingCache()
|
||||
ratio_setting.InvalidateExposedDataCache()
|
||||
} else if configName == "theme" {
|
||||
system_setting.UpdateAndSyncTheme()
|
||||
}
|
||||
|
||||
return true // 已处理
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// PerfMetric stores aggregated relay performance metrics for the model square.
|
||||
type PerfMetric struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"`
|
||||
Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
|
||||
BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
|
||||
RequestCount int64 `json:"-" gorm:"default:0"`
|
||||
SuccessCount int64 `json:"-" gorm:"default:0"`
|
||||
TotalLatencyMs int64 `json:"-" gorm:"default:0"`
|
||||
TtftSumMs int64 `json:"-" gorm:"default:0"`
|
||||
TtftCount int64 `json:"-" gorm:"default:0"`
|
||||
OutputTokens int64 `json:"-" gorm:"default:0"`
|
||||
GenerationMs int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
func (PerfMetric) TableName() string {
|
||||
return "perf_metrics"
|
||||
}
|
||||
|
||||
func UpsertPerfMetric(metric *PerfMetric) error {
|
||||
if metric == nil || metric.RequestCount == 0 {
|
||||
return nil
|
||||
}
|
||||
return DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "model_name"},
|
||||
{Name: "group"},
|
||||
{Name: "bucket_ts"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
|
||||
}),
|
||||
}).Create(metric).Error
|
||||
}
|
||||
|
||||
func GetPerfMetrics(modelName string, group string, startTs int64, endTs int64) ([]PerfMetric, error) {
|
||||
var metrics []PerfMetric
|
||||
query := DB.Model(&PerfMetric{}).
|
||||
Where("model_name = ? AND bucket_ts >= ? AND bucket_ts <= ?", modelName, startTs, endTs)
|
||||
if group != "" {
|
||||
query = query.Where(commonGroupCol+" = ?", group)
|
||||
}
|
||||
err := query.Order("bucket_ts ASC").Find(&metrics).Error
|
||||
return metrics, err
|
||||
}
|
||||
|
||||
func DeletePerfMetricsBefore(cutoffTs int64) error {
|
||||
if cutoffTs <= 0 {
|
||||
return nil
|
||||
}
|
||||
return DB.Where("bucket_ts < ?", cutoffTs).Delete(&PerfMetric{}).Error
|
||||
}
|
||||
|
||||
func PerfMetricStartTime(hours int) int64 {
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
return time.Now().Add(-time.Duration(hours) * time.Hour).Unix()
|
||||
}
|
||||
@@ -416,6 +416,17 @@ 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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RankingQuotaTotal struct {
|
||||
ModelName string `json:"model_name"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type RankingQuotaBucket struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Bucket int64 `json:"bucket"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
func GetRankingQuotaTotals(startTime int64, endTime int64) ([]RankingQuotaTotal, error) {
|
||||
var rows []RankingQuotaTotal
|
||||
query := DB.Table("quota_data").
|
||||
Select("model_name, sum(token_used) as total_tokens").
|
||||
Where("model_name <> ''").
|
||||
Group("model_name").
|
||||
Having("sum(token_used) > 0").
|
||||
Order("total_tokens DESC")
|
||||
query = applyRankingQuotaTimeRange(query, startTime, endTime)
|
||||
err := query.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func GetRankingQuotaBuckets(startTime int64, endTime int64, bucketSize int64) ([]RankingQuotaBucket, error) {
|
||||
if bucketSize <= 0 {
|
||||
bucketSize = 3600
|
||||
}
|
||||
bucketExpr := rankingBucketExpr(bucketSize)
|
||||
var rows []RankingQuotaBucket
|
||||
query := DB.Table("quota_data").
|
||||
Select(fmt.Sprintf("model_name, %s as bucket, sum(token_used) as tokens", bucketExpr)).
|
||||
Where("model_name <> ''").
|
||||
Group(fmt.Sprintf("model_name, %s", bucketExpr)).
|
||||
Having("sum(token_used) > 0").
|
||||
Order("bucket ASC")
|
||||
query = applyRankingQuotaTimeRange(query, startTime, endTime)
|
||||
err := query.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func rankingBucketExpr(bucketSize int64) string {
|
||||
if common.UsingMySQL {
|
||||
return fmt.Sprintf("FLOOR(created_at / %d) * %d", bucketSize, bucketSize)
|
||||
}
|
||||
return fmt.Sprintf("(created_at / %d) * %d", bucketSize, bucketSize)
|
||||
}
|
||||
|
||||
func applyRankingQuotaTimeRange(query *gorm.DB, startTime int64, endTime int64) *gorm.DB {
|
||||
if startTime > 0 {
|
||||
query = query.Where("created_at >= ?", startTime)
|
||||
}
|
||||
if endTime > 0 {
|
||||
query = query.Where("created_at <= ?", endTime)
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/perf_metrics_setting"
|
||||
)
|
||||
|
||||
func flushLoop() {
|
||||
for {
|
||||
interval := perf_metrics_setting.GetFlushIntervalMinutes()
|
||||
time.Sleep(time.Duration(interval) * time.Minute)
|
||||
setting := perf_metrics_setting.GetSetting()
|
||||
if !setting.Enabled {
|
||||
continue
|
||||
}
|
||||
flushCompletedBuckets()
|
||||
cleanupExpiredMetrics(setting.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func flushCompletedBuckets() {
|
||||
currentBucket := bucketStart(time.Now().Unix())
|
||||
hotBuckets.Range(func(key, value any) bool {
|
||||
k := key.(bucketKey)
|
||||
if k.bucketTs >= currentBucket {
|
||||
return true
|
||||
}
|
||||
|
||||
bucket := value.(*atomicBucket)
|
||||
drained := bucket.drain()
|
||||
if drained.requestCount == 0 {
|
||||
deleteOldEmptyBucket(k, key)
|
||||
return true
|
||||
}
|
||||
|
||||
err := model.UpsertPerfMetric(&model.PerfMetric{
|
||||
ModelName: k.model,
|
||||
Group: k.group,
|
||||
BucketTs: k.bucketTs,
|
||||
RequestCount: drained.requestCount,
|
||||
SuccessCount: drained.successCount,
|
||||
TotalLatencyMs: drained.totalLatencyMs,
|
||||
TtftSumMs: drained.ttftSumMs,
|
||||
TtftCount: drained.ttftCount,
|
||||
OutputTokens: drained.outputTokens,
|
||||
GenerationMs: drained.generationMs,
|
||||
})
|
||||
if err != nil {
|
||||
bucket.addCounters(drained)
|
||||
common.SysError(fmt.Sprintf("failed to flush perf metric bucket model=%s group=%s bucket=%d: %s", k.model, k.group, k.bucketTs, err.Error()))
|
||||
return true
|
||||
}
|
||||
|
||||
deleteOldEmptyBucket(k, key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func deleteOldEmptyBucket(k bucketKey, rawKey any) {
|
||||
if k.bucketTs < bucketStart(time.Now().Add(-24*time.Hour).Unix()) {
|
||||
hotBuckets.Delete(rawKey)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupExpiredMetrics(retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix()
|
||||
if err := model.DeletePerfMetricsBefore(cutoff); err != nil {
|
||||
common.SysError("failed to cleanup expired perf metrics: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func redisCounters(values map[string]string) counters {
|
||||
return counters{
|
||||
requestCount: parseRedisInt(values["req"]),
|
||||
successCount: parseRedisInt(values["ok"]),
|
||||
totalLatencyMs: parseRedisInt(values["lat"]),
|
||||
ttftSumMs: parseRedisInt(values["ttft"]),
|
||||
ttftCount: parseRedisInt(values["ttft_n"]),
|
||||
outputTokens: parseRedisInt(values["out"]),
|
||||
generationMs: parseRedisInt(values["gen_ms"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseRedisInt(value string) int64 {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package perfmetrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/perf_metrics_setting"
|
||||
)
|
||||
|
||||
var hotBuckets sync.Map
|
||||
|
||||
// seriesSchema is a stable client cache/schema marker. Do not change it when
|
||||
// hiding fields or making response-only privacy hardening changes.
|
||||
const seriesSchema = "dbcd0a3c01b55203"
|
||||
|
||||
func Init() {
|
||||
go flushLoop()
|
||||
}
|
||||
|
||||
func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
hasTtft := info.IsStream && info.HasSendResponse()
|
||||
ttftMs := int64(0)
|
||||
if hasTtft {
|
||||
ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds()
|
||||
}
|
||||
latencyMs := now.Sub(info.StartTime).Milliseconds()
|
||||
generationMs := latencyMs
|
||||
if hasTtft {
|
||||
generationMs = now.Sub(info.FirstResponseTime).Milliseconds()
|
||||
}
|
||||
if generationMs <= 0 {
|
||||
generationMs = latencyMs
|
||||
}
|
||||
Record(Sample{
|
||||
Model: info.OriginModelName,
|
||||
Group: info.UsingGroup,
|
||||
LatencyMs: latencyMs,
|
||||
TtftMs: ttftMs,
|
||||
HasTtft: hasTtft,
|
||||
Success: success,
|
||||
OutputTokens: outputTokens,
|
||||
GenerationMs: generationMs,
|
||||
})
|
||||
}
|
||||
|
||||
func Record(sample Sample) {
|
||||
setting := perf_metrics_setting.GetSetting()
|
||||
if !setting.Enabled || sample.Model == "" {
|
||||
return
|
||||
}
|
||||
if sample.Group == "" {
|
||||
sample.Group = "default"
|
||||
}
|
||||
if sample.LatencyMs < 0 {
|
||||
sample.LatencyMs = 0
|
||||
}
|
||||
|
||||
key := bucketKey{
|
||||
model: sample.Model,
|
||||
group: sample.Group,
|
||||
bucketTs: bucketStart(time.Now().Unix()),
|
||||
}
|
||||
actual, _ := hotBuckets.LoadOrStore(key, &atomicBucket{})
|
||||
actual.(*atomicBucket).add(sample)
|
||||
recordRedis(key, sample)
|
||||
}
|
||||
|
||||
func Query(params QueryParams) (QueryResult, error) {
|
||||
if params.Hours <= 0 {
|
||||
params.Hours = 24
|
||||
}
|
||||
if params.Hours > 24*30 {
|
||||
params.Hours = 24 * 30
|
||||
}
|
||||
endTs := time.Now().Unix()
|
||||
startTs := endTs - int64(params.Hours)*3600
|
||||
|
||||
merged := map[bucketKey]counters{}
|
||||
rows, err := model.GetPerfMetrics(params.Model, params.Group, startTs, endTs)
|
||||
if err != nil {
|
||||
return QueryResult{}, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
mergeCounters(merged, bucketKey{
|
||||
model: row.ModelName,
|
||||
group: row.Group,
|
||||
bucketTs: row.BucketTs,
|
||||
}, counters{
|
||||
requestCount: row.RequestCount,
|
||||
successCount: row.SuccessCount,
|
||||
totalLatencyMs: row.TotalLatencyMs,
|
||||
ttftSumMs: row.TtftSumMs,
|
||||
ttftCount: row.TtftCount,
|
||||
outputTokens: row.OutputTokens,
|
||||
generationMs: row.GenerationMs,
|
||||
})
|
||||
}
|
||||
|
||||
hotBuckets.Range(func(key, value any) bool {
|
||||
k := key.(bucketKey)
|
||||
if k.model != params.Model || k.bucketTs < startTs || k.bucketTs > endTs {
|
||||
return true
|
||||
}
|
||||
if params.Group != "" && k.group != params.Group {
|
||||
return true
|
||||
}
|
||||
mergeCounters(merged, k, value.(*atomicBucket).snapshot())
|
||||
return true
|
||||
})
|
||||
|
||||
return buildQueryResult(params.Model, merged), nil
|
||||
}
|
||||
|
||||
func bucketStart(ts int64) int64 {
|
||||
bucketSeconds := perf_metrics_setting.GetBucketSeconds()
|
||||
if bucketSeconds <= 0 {
|
||||
bucketSeconds = 3600
|
||||
}
|
||||
return ts - (ts % bucketSeconds)
|
||||
}
|
||||
|
||||
func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters) {
|
||||
if value.requestCount == 0 {
|
||||
return
|
||||
}
|
||||
current := merged[key]
|
||||
current.requestCount += value.requestCount
|
||||
current.successCount += value.successCount
|
||||
current.totalLatencyMs += value.totalLatencyMs
|
||||
current.ttftSumMs += value.ttftSumMs
|
||||
current.ttftCount += value.ttftCount
|
||||
current.outputTokens += value.outputTokens
|
||||
current.generationMs += value.generationMs
|
||||
merged[key] = current
|
||||
}
|
||||
|
||||
func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResult {
|
||||
groupBuckets := map[string]map[int64]counters{}
|
||||
for key, value := range merged {
|
||||
if value.requestCount == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := groupBuckets[key.group]; !ok {
|
||||
groupBuckets[key.group] = map[int64]counters{}
|
||||
}
|
||||
groupBuckets[key.group][key.bucketTs] = value
|
||||
}
|
||||
|
||||
groups := make([]string, 0, len(groupBuckets))
|
||||
for group := range groupBuckets {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
sort.Strings(groups)
|
||||
|
||||
results := make([]GroupResult, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
buckets := groupBuckets[group]
|
||||
timestamps := make([]int64, 0, len(buckets))
|
||||
for ts := range buckets {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
sort.Slice(timestamps, func(i, j int) bool {
|
||||
return timestamps[i] < timestamps[j]
|
||||
})
|
||||
|
||||
total := counters{}
|
||||
series := make([]BucketPoint, 0, len(timestamps))
|
||||
for _, ts := range timestamps {
|
||||
value := buckets[ts]
|
||||
total.requestCount += value.requestCount
|
||||
total.successCount += value.successCount
|
||||
total.totalLatencyMs += value.totalLatencyMs
|
||||
total.ttftSumMs += value.ttftSumMs
|
||||
total.ttftCount += value.ttftCount
|
||||
total.outputTokens += value.outputTokens
|
||||
total.generationMs += value.generationMs
|
||||
series = append(series, bucketPoint(ts, value))
|
||||
}
|
||||
|
||||
results = append(results, GroupResult{
|
||||
Group: group,
|
||||
AvgTtftMs: avg(total.ttftSumMs, total.ttftCount),
|
||||
AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
|
||||
SuccessRate: successRate(total),
|
||||
AvgTps: avgTps(total),
|
||||
Series: series,
|
||||
})
|
||||
}
|
||||
|
||||
return QueryResult{
|
||||
ModelName: modelName,
|
||||
SeriesSchema: seriesSchema,
|
||||
Groups: results,
|
||||
}
|
||||
}
|
||||
|
||||
func bucketPoint(ts int64, value counters) BucketPoint {
|
||||
return BucketPoint{
|
||||
Ts: ts,
|
||||
AvgTtftMs: avg(value.ttftSumMs, value.ttftCount),
|
||||
AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
|
||||
SuccessRate: successRate(value),
|
||||
AvgTps: avgTps(value),
|
||||
}
|
||||
}
|
||||
|
||||
func avg(sum int64, count int64) int64 {
|
||||
if count <= 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
func successRate(value counters) float64 {
|
||||
if value.requestCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(value.successCount) / float64(value.requestCount) * 100
|
||||
}
|
||||
|
||||
func avgTps(value counters) float64 {
|
||||
if value.outputTokens <= 0 || value.generationMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(value.outputTokens) / (float64(value.generationMs) / 1000)
|
||||
}
|
||||
|
||||
func recordRedis(key bucketKey, sample Sample) {
|
||||
if !common.RedisEnabled || common.RDB == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
redisKey := redisBucketKey(key)
|
||||
pipe := common.RDB.TxPipeline()
|
||||
pipe.HIncrBy(ctx, redisKey, "req", 1)
|
||||
if sample.Success {
|
||||
pipe.HIncrBy(ctx, redisKey, "ok", 1)
|
||||
}
|
||||
if sample.LatencyMs > 0 {
|
||||
pipe.HIncrBy(ctx, redisKey, "lat", sample.LatencyMs)
|
||||
}
|
||||
if sample.HasTtft && sample.TtftMs >= 0 {
|
||||
pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
|
||||
pipe.HIncrBy(ctx, redisKey, "ttft_n", 1)
|
||||
}
|
||||
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
|
||||
pipe.HIncrBy(ctx, redisKey, "out", sample.OutputTokens)
|
||||
pipe.HIncrBy(ctx, redisKey, "gen_ms", sample.GenerationMs)
|
||||
}
|
||||
pipe.Expire(ctx, redisKey, time.Hour)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
func mergeRedisActiveBuckets(merged map[bucketKey]counters, params QueryParams, startTs int64, endTs int64) {
|
||||
if !common.RedisEnabled || common.RDB == nil || params.Model == "" || params.Group == "" {
|
||||
return
|
||||
}
|
||||
active := bucketStart(time.Now().Unix())
|
||||
if active < startTs || active > endTs {
|
||||
return
|
||||
}
|
||||
key := bucketKey{model: params.Model, group: params.Group, bucketTs: active}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
values, err := common.RDB.HGetAll(ctx, redisBucketKey(key)).Result()
|
||||
if err != nil || len(values) == 0 {
|
||||
return
|
||||
}
|
||||
mergeCounters(merged, key, redisCounters(values))
|
||||
}
|
||||
|
||||
func redisBucketKey(key bucketKey) string {
|
||||
return fmt.Sprintf("perf:%s:%s:%d", key.model, key.group, key.bucketTs)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package perfmetrics
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
type Store interface {
|
||||
Record(sample Sample)
|
||||
Query(params QueryParams) (QueryResult, error)
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
Model string
|
||||
Group string
|
||||
LatencyMs int64
|
||||
TtftMs int64
|
||||
HasTtft bool
|
||||
Success bool
|
||||
OutputTokens int64
|
||||
GenerationMs int64
|
||||
}
|
||||
|
||||
type QueryParams struct {
|
||||
Model string
|
||||
Group string
|
||||
Hours int
|
||||
}
|
||||
|
||||
type BucketPoint struct {
|
||||
Ts int64 `json:"ts"`
|
||||
AvgTtftMs int64 `json:"avg_ttft_ms"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
}
|
||||
|
||||
type GroupResult struct {
|
||||
Group string `json:"group"`
|
||||
AvgTtftMs int64 `json:"avg_ttft_ms"`
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
Series []BucketPoint `json:"series"`
|
||||
}
|
||||
|
||||
type QueryResult struct {
|
||||
ModelName string `json:"model_name"`
|
||||
SeriesSchema string `json:"series_schema"`
|
||||
Groups []GroupResult `json:"groups"`
|
||||
}
|
||||
|
||||
type bucketKey struct {
|
||||
model string
|
||||
group string
|
||||
bucketTs int64
|
||||
}
|
||||
|
||||
type counters struct {
|
||||
requestCount int64
|
||||
successCount int64
|
||||
totalLatencyMs int64
|
||||
ttftSumMs int64
|
||||
ttftCount int64
|
||||
outputTokens int64
|
||||
generationMs int64
|
||||
}
|
||||
|
||||
type atomicBucket struct {
|
||||
requestCount atomic.Int64
|
||||
successCount atomic.Int64
|
||||
totalLatencyMs atomic.Int64
|
||||
ttftSumMs atomic.Int64
|
||||
ttftCount atomic.Int64
|
||||
outputTokens atomic.Int64
|
||||
generationMs atomic.Int64
|
||||
}
|
||||
|
||||
func (b *atomicBucket) add(sample Sample) {
|
||||
b.requestCount.Add(1)
|
||||
if sample.Success {
|
||||
b.successCount.Add(1)
|
||||
}
|
||||
if sample.LatencyMs > 0 {
|
||||
b.totalLatencyMs.Add(sample.LatencyMs)
|
||||
}
|
||||
if sample.HasTtft && sample.TtftMs >= 0 {
|
||||
b.ttftSumMs.Add(sample.TtftMs)
|
||||
b.ttftCount.Add(1)
|
||||
}
|
||||
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
|
||||
b.outputTokens.Add(sample.OutputTokens)
|
||||
b.generationMs.Add(sample.GenerationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *atomicBucket) snapshot() counters {
|
||||
return counters{
|
||||
requestCount: b.requestCount.Load(),
|
||||
successCount: b.successCount.Load(),
|
||||
totalLatencyMs: b.totalLatencyMs.Load(),
|
||||
ttftSumMs: b.ttftSumMs.Load(),
|
||||
ttftCount: b.ttftCount.Load(),
|
||||
outputTokens: b.outputTokens.Load(),
|
||||
generationMs: b.generationMs.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *atomicBucket) drain() counters {
|
||||
return counters{
|
||||
requestCount: b.requestCount.Swap(0),
|
||||
successCount: b.successCount.Swap(0),
|
||||
totalLatencyMs: b.totalLatencyMs.Swap(0),
|
||||
ttftSumMs: b.ttftSumMs.Swap(0),
|
||||
ttftCount: b.ttftCount.Swap(0),
|
||||
outputTokens: b.outputTokens.Swap(0),
|
||||
generationMs: b.generationMs.Swap(0),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *atomicBucket) addCounters(c counters) {
|
||||
if c.requestCount != 0 {
|
||||
b.requestCount.Add(c.requestCount)
|
||||
}
|
||||
if c.successCount != 0 {
|
||||
b.successCount.Add(c.successCount)
|
||||
}
|
||||
if c.totalLatencyMs != 0 {
|
||||
b.totalLatencyMs.Add(c.totalLatencyMs)
|
||||
}
|
||||
if c.ttftSumMs != 0 {
|
||||
b.ttftSumMs.Add(c.ttftSumMs)
|
||||
}
|
||||
if c.ttftCount != 0 {
|
||||
b.ttftCount.Add(c.ttftCount)
|
||||
}
|
||||
if c.outputTokens != 0 {
|
||||
b.outputTokens.Add(c.outputTokens)
|
||||
}
|
||||
if c.generationMs != 0 {
|
||||
b.generationMs.Add(c.generationMs)
|
||||
}
|
||||
}
|
||||
@@ -567,12 +567,14 @@ 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)
|
||||
}
|
||||
choice.Message.ReasoningContent = thinkingContent
|
||||
if thinkingContent != "" {
|
||||
choice.Message.ReasoningContent = &thinkingContent
|
||||
}
|
||||
fullTextResponse.Model = claudeResponse.Model
|
||||
choices = append(choices, choice)
|
||||
fullTextResponse.Choices = choices
|
||||
|
||||
@@ -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```")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
|
||||
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.GetReasoningContent(), info.UpstreamModelName)
|
||||
completionTokens += ctkm
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,20 +95,7 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
|
||||
if strings.TrimSpace(region) == "" {
|
||||
region = "global"
|
||||
}
|
||||
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
|
||||
return vertexcore.BuildGoogleModelURL(a.baseURL, vertexcore.DefaultAPIVersion, adc.ProjectID, region, modelName, "predictLongRunning"), nil
|
||||
}
|
||||
|
||||
// BuildRequestHeader sets required headers.
|
||||
@@ -238,6 +225,22 @@ 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)
|
||||
@@ -248,20 +251,9 @@ 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)
|
||||
}
|
||||
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)
|
||||
url, err := buildFetchOperationURL(baseUrl, upstreamName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := fetchOperationPayload{OperationName: upstreamName}
|
||||
data, err := common.Marshal(payload)
|
||||
|
||||
@@ -134,47 +134,11 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
||||
a.AccountCredentials = *adc
|
||||
|
||||
if a.RequestMode == RequestModeGemini {
|
||||
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
|
||||
}
|
||||
return BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
|
||||
} else if a.RequestMode == RequestModeClaude {
|
||||
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
|
||||
}
|
||||
return BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, adc.ProjectID, region, modelName, suffix), nil
|
||||
} else if a.RequestMode == RequestModeOpenSource {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
||||
adc.ProjectID,
|
||||
region,
|
||||
), nil
|
||||
return BuildOpenSourceChatCompletionsURL(info.ChannelBaseUrl, adc.ProjectID, region), nil
|
||||
}
|
||||
} else {
|
||||
var keyPrefix string
|
||||
@@ -183,20 +147,17 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
||||
} else {
|
||||
keyPrefix = "?"
|
||||
}
|
||||
if region == "global" {
|
||||
if a.RequestMode == RequestModeGemini {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
|
||||
modelName,
|
||||
suffix,
|
||||
"%s%skey=%s",
|
||||
BuildGoogleModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
|
||||
keyPrefix,
|
||||
info.ApiKey,
|
||||
), nil
|
||||
} else {
|
||||
} else if a.RequestMode == RequestModeClaude {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s",
|
||||
region,
|
||||
modelName,
|
||||
suffix,
|
||||
"%s%skey=%s",
|
||||
BuildAnthropicModelURL(info.ChannelBaseUrl, DefaultAPIVersion, "", region, modelName, suffix),
|
||||
keyPrefix,
|
||||
info.ApiKey,
|
||||
), nil
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package vertex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultAPIVersion = "v1"
|
||||
OpenSourceAPIVersion = "v1beta1"
|
||||
PublisherGoogle = "google"
|
||||
PublisherAnthropic = "anthropic"
|
||||
)
|
||||
|
||||
func normalizeVertexBaseURL(baseURL string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
}
|
||||
|
||||
func normalizeVertexRegion(region string) string {
|
||||
region = strings.TrimSpace(region)
|
||||
if region == "" {
|
||||
return "global"
|
||||
}
|
||||
return region
|
||||
}
|
||||
|
||||
func appendVertexAPIVersion(baseURL, version string) string {
|
||||
version = strings.Trim(strings.TrimSpace(version), "/")
|
||||
if version == "" {
|
||||
return baseURL
|
||||
}
|
||||
if strings.HasSuffix(baseURL, "/"+version) {
|
||||
return baseURL
|
||||
}
|
||||
return baseURL + "/" + version
|
||||
}
|
||||
|
||||
func BuildAPIBaseURL(baseURL, version, projectID, region string) string {
|
||||
if normalized := normalizeVertexBaseURL(baseURL); normalized != "" {
|
||||
normalized = appendVertexAPIVersion(normalized, version)
|
||||
|
||||
region = normalizeVertexRegion(region)
|
||||
if strings.TrimSpace(projectID) != "" {
|
||||
normalized = fmt.Sprintf("%s/projects/%s/locations/%s", normalized, projectID, region)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
region = normalizeVertexRegion(region)
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
if region == "global" {
|
||||
return fmt.Sprintf("https://aiplatform.googleapis.com/%s", version)
|
||||
}
|
||||
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s", region, version)
|
||||
}
|
||||
|
||||
if region == "global" {
|
||||
return fmt.Sprintf("https://aiplatform.googleapis.com/%s/projects/%s/locations/global", version, projectID)
|
||||
}
|
||||
return fmt.Sprintf("https://%s-aiplatform.googleapis.com/%s/projects/%s/locations/%s", region, version, projectID, region)
|
||||
}
|
||||
|
||||
func BuildPublisherModelURL(baseURL, version, projectID, region, publisher, modelName, action string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s/publishers/%s/models/%s:%s",
|
||||
BuildAPIBaseURL(baseURL, version, projectID, region),
|
||||
publisher,
|
||||
modelName,
|
||||
action,
|
||||
)
|
||||
}
|
||||
|
||||
func BuildGoogleModelURL(baseURL, version, projectID, region, modelName, action string) string {
|
||||
return BuildPublisherModelURL(baseURL, version, projectID, region, PublisherGoogle, modelName, action)
|
||||
}
|
||||
|
||||
func BuildAnthropicModelURL(baseURL, version, projectID, region, modelName, action string) string {
|
||||
return BuildPublisherModelURL(baseURL, version, projectID, region, PublisherAnthropic, modelName, action)
|
||||
}
|
||||
|
||||
func BuildOpenSourceChatCompletionsURL(baseURL, projectID, region string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s/endpoints/openapi/chat/completions",
|
||||
BuildAPIBaseURL(baseURL, OpenSourceAPIVersion, projectID, region),
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics)
|
||||
apiRouter.GET("/rankings", controller.GetRankings)
|
||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
func SetRouter(router *gin.Engine, assets ThemeAssets) {
|
||||
SetApiRouter(router)
|
||||
SetDashboardRouter(router)
|
||||
SetRelayRouter(router)
|
||||
@@ -24,7 +23,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
|
||||
}
|
||||
if frontendBaseUrl == "" {
|
||||
SetWebRouter(router, buildFS, indexPage)
|
||||
SetWebRouter(router, assets)
|
||||
} else {
|
||||
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
|
||||
@@ -13,11 +13,23 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
// ThemeAssets holds the embedded frontend assets for both themes.
|
||||
type ThemeAssets struct {
|
||||
DefaultBuildFS embed.FS
|
||||
DefaultIndexPage []byte
|
||||
ClassicBuildFS embed.FS
|
||||
ClassicIndexPage []byte
|
||||
}
|
||||
|
||||
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
|
||||
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
|
||||
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
|
||||
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
|
||||
|
||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
router.Use(middleware.GlobalWebRateLimit())
|
||||
router.Use(middleware.Cache())
|
||||
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist")))
|
||||
router.Use(static.Serve("/", themeFS))
|
||||
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") {
|
||||
@@ -25,6 +37,10 @@ func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage)
|
||||
if common.GetTheme() == "classic" {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
|
||||
} else {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||
if err != nil {
|
||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||
// It returns the pre-consumed quota if successful, or an error if not.
|
||||
func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
if userQuota <= 0 {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
if userQuota-preConsumedQuota < 0 {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
|
||||
trustQuota := common.GetTrustQuota()
|
||||
|
||||
relayInfo.UserQuota = userQuota
|
||||
if userQuota > trustQuota {
|
||||
// 用户额度充足,判断令牌额度是否充足
|
||||
if !relayInfo.TokenUnlimited {
|
||||
// 非无限令牌,判断令牌额度是否充足
|
||||
tokenQuota := c.GetInt("token_quota")
|
||||
if tokenQuota > trustQuota {
|
||||
// 令牌额度充足,信任令牌
|
||||
preConsumedQuota = 0
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota))
|
||||
}
|
||||
} else {
|
||||
// in this case, we do not pre-consume quota
|
||||
// because the user has enough quota
|
||||
preConsumedQuota = 0
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId))
|
||||
}
|
||||
}
|
||||
|
||||
if preConsumedQuota > 0 {
|
||||
err := PreConsumeTokenQuota(relayInfo, preConsumedQuota)
|
||||
if err != nil {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota, false)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota)))
|
||||
}
|
||||
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||
return nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
@@ -219,7 +220,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
|
||||
// in this case, must be some error happened
|
||||
// we cannot just return, because we may have to return the pre-consumed quota
|
||||
quota = 0
|
||||
logContent += fmt.Sprintf("(可能是上游超时)")
|
||||
logContent += "(可能是上游超时)"
|
||||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
|
||||
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, relayInfo.FinalPreConsumedQuota))
|
||||
} else {
|
||||
@@ -340,7 +341,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
|
||||
// in this case, must be some error happened
|
||||
// we cannot just return, because we may have to return the pre-consumed quota
|
||||
quota = 0
|
||||
logContent += fmt.Sprintf("(可能是上游超时)")
|
||||
logContent += "(可能是上游超时)"
|
||||
logger.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
|
||||
"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, relayInfo.OriginModelName, relayInfo.FinalPreConsumedQuota))
|
||||
} else {
|
||||
@@ -375,6 +376,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
|
||||
})
|
||||
}
|
||||
|
||||
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
const (
|
||||
rankingCacheTTL = 5 * time.Minute
|
||||
rankingLeaderboardLimit = 20
|
||||
rankingHistoryLimit = 10
|
||||
rankingVendorLimit = 5
|
||||
rankingMoverLimit = 6
|
||||
rankingOthersLabel = "Others"
|
||||
rankingUnknownVendor = "Unknown"
|
||||
)
|
||||
|
||||
type RankingsResponse struct {
|
||||
Models []RankedModel `json:"models"`
|
||||
Vendors []RankedVendor `json:"vendors"`
|
||||
TopMovers []RankingMover `json:"top_movers"`
|
||||
TopDroppers []RankingMover `json:"top_droppers"`
|
||||
ModelsHistory ModelHistorySeries `json:"models_history"`
|
||||
VendorShareHistory VendorShareSeries `json:"vendor_share_history"`
|
||||
}
|
||||
|
||||
type RankedModel struct {
|
||||
Rank int `json:"rank"`
|
||||
PreviousRank *int `json:"previous_rank,omitempty"`
|
||||
ModelName string `json:"model_name"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
Category string `json:"category"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Share float64 `json:"share"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
}
|
||||
|
||||
type RankedVendor struct {
|
||||
Rank int `json:"rank"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
Share float64 `json:"share"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
ModelsCount int `json:"models_count"`
|
||||
TopModel string `json:"top_model"`
|
||||
}
|
||||
|
||||
type RankingMover struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Vendor string `json:"vendor"`
|
||||
VendorIcon string `json:"vendor_icon,omitempty"`
|
||||
RankDelta int `json:"rank_delta"`
|
||||
CurrentRank int `json:"current_rank"`
|
||||
GrowthPct float64 `json:"growth_pct"`
|
||||
}
|
||||
|
||||
type ModelHistoryPoint struct {
|
||||
Ts string `json:"ts"`
|
||||
Label string `json:"label"`
|
||||
Model string `json:"model"`
|
||||
Vendor string `json:"vendor"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
type ModelHistoryModel struct {
|
||||
Name string `json:"name"`
|
||||
Vendor string `json:"vendor"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type ModelHistorySeries struct {
|
||||
Points []ModelHistoryPoint `json:"points"`
|
||||
Models []ModelHistoryModel `json:"models"`
|
||||
Buckets int `json:"buckets"`
|
||||
}
|
||||
|
||||
type VendorSharePoint struct {
|
||||
Ts string `json:"ts"`
|
||||
Label string `json:"label"`
|
||||
Vendor string `json:"vendor"`
|
||||
Share float64 `json:"share"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
}
|
||||
|
||||
type VendorShareVendor struct {
|
||||
Name string `json:"name"`
|
||||
Total int64 `json:"total"`
|
||||
Share float64 `json:"share"`
|
||||
}
|
||||
|
||||
type VendorShareSeries struct {
|
||||
Points []VendorSharePoint `json:"points"`
|
||||
Vendors []VendorShareVendor `json:"vendors"`
|
||||
Buckets int `json:"buckets"`
|
||||
}
|
||||
|
||||
type rankingPeriodConfig struct {
|
||||
id string
|
||||
duration time.Duration
|
||||
bucketSize int64
|
||||
labelLayout string
|
||||
hasPrevious bool
|
||||
}
|
||||
|
||||
type rankingCacheItem struct {
|
||||
expiresAt time.Time
|
||||
data *RankingsResponse
|
||||
}
|
||||
|
||||
type rankingModelMeta struct {
|
||||
vendor string
|
||||
vendorIcon string
|
||||
}
|
||||
|
||||
type vendorAggregate struct {
|
||||
name string
|
||||
icon string
|
||||
totalTokens int64
|
||||
previousTokens int64
|
||||
models map[string]struct{}
|
||||
topModel string
|
||||
topModelTokens int64
|
||||
}
|
||||
|
||||
var (
|
||||
rankingCacheMu sync.Mutex
|
||||
rankingCache = map[string]rankingCacheItem{}
|
||||
)
|
||||
|
||||
func GetRankingsSnapshot(period string) (*RankingsResponse, error) {
|
||||
config, err := rankingConfig(period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rankingCacheMu.Lock()
|
||||
if item, ok := rankingCache[config.id]; ok && now.Before(item.expiresAt) {
|
||||
rankingCacheMu.Unlock()
|
||||
return item.data, nil
|
||||
}
|
||||
rankingCacheMu.Unlock()
|
||||
|
||||
data, err := buildRankingsSnapshot(config, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rankingCacheMu.Lock()
|
||||
rankingCache[config.id] = rankingCacheItem{
|
||||
expiresAt: now.Add(rankingCacheTTL),
|
||||
data: data,
|
||||
}
|
||||
rankingCacheMu.Unlock()
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func rankingConfig(period string) (rankingPeriodConfig, error) {
|
||||
switch period {
|
||||
case "", "week":
|
||||
return rankingPeriodConfig{id: "week", duration: 7 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "today":
|
||||
return rankingPeriodConfig{id: "today", duration: 24 * time.Hour, bucketSize: 3600, labelLayout: "15:04", hasPrevious: true}, nil
|
||||
case "month":
|
||||
return rankingPeriodConfig{id: "month", duration: 30 * 24 * time.Hour, bucketSize: 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "year":
|
||||
return rankingPeriodConfig{id: "year", duration: 365 * 24 * time.Hour, bucketSize: 7 * 24 * 3600, labelLayout: "Jan 2", hasPrevious: true}, nil
|
||||
case "all":
|
||||
return rankingPeriodConfig{id: "all", bucketSize: 30 * 24 * 3600, labelLayout: "Jan 2006"}, nil
|
||||
default:
|
||||
return rankingPeriodConfig{}, fmt.Errorf("invalid ranking period: %s", period)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRankingsSnapshot(config rankingPeriodConfig, now time.Time) (*RankingsResponse, error) {
|
||||
startTime, endTime := rankingTimeRange(config, now)
|
||||
currentTotals, err := model.GetRankingQuotaTotals(startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentBuckets, err := model.GetRankingQuotaBuckets(startTime, endTime, config.bucketSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var previousTotals []model.RankingQuotaTotal
|
||||
if config.hasPrevious {
|
||||
previousStart, previousEnd := previousRankingTimeRange(config, startTime)
|
||||
previousTotals, err = model.GetRankingQuotaTotals(previousStart, previousEnd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meta := buildRankingModelMeta()
|
||||
totalTokens := sumRankingTokens(currentTotals)
|
||||
previousRankByModel := rankingRankMap(previousTotals)
|
||||
previousTokensByModel := rankingTokenMap(previousTotals)
|
||||
|
||||
rankedModels := buildRankedModels(currentTotals, totalTokens, previousRankByModel, previousTokensByModel, meta, config.hasPrevious)
|
||||
vendors := buildRankedVendors(currentTotals, previousTotals, totalTokens, meta, config.hasPrevious)
|
||||
modelHistory := buildModelHistory(currentBuckets, currentTotals, meta, config)
|
||||
vendorHistory := buildVendorShareHistory(currentBuckets, vendors, totalTokens, meta, config)
|
||||
movers, droppers := buildRankingMovers(rankedModels)
|
||||
|
||||
return &RankingsResponse{
|
||||
Models: limitRankedModels(rankedModels, rankingLeaderboardLimit),
|
||||
Vendors: vendors,
|
||||
TopMovers: movers,
|
||||
TopDroppers: droppers,
|
||||
ModelsHistory: modelHistory,
|
||||
VendorShareHistory: vendorHistory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rankingTimeRange(config rankingPeriodConfig, now time.Time) (int64, int64) {
|
||||
endTime := now.Unix()
|
||||
if config.duration <= 0 {
|
||||
return 0, endTime
|
||||
}
|
||||
return now.Add(-config.duration).Unix(), endTime
|
||||
}
|
||||
|
||||
func previousRankingTimeRange(config rankingPeriodConfig, currentStart int64) (int64, int64) {
|
||||
previousEnd := currentStart - 1
|
||||
previousStart := time.Unix(currentStart, 0).Add(-config.duration).Unix()
|
||||
return previousStart, previousEnd
|
||||
}
|
||||
|
||||
func buildRankingModelMeta() map[string]rankingModelMeta {
|
||||
vendorByID := make(map[int]model.PricingVendor)
|
||||
for _, vendor := range model.GetVendors() {
|
||||
vendorByID[vendor.ID] = vendor
|
||||
}
|
||||
|
||||
meta := make(map[string]rankingModelMeta)
|
||||
for _, pricing := range model.GetPricing() {
|
||||
item := rankingModelMeta{vendor: rankingUnknownVendor}
|
||||
if vendor, ok := vendorByID[pricing.VendorID]; ok {
|
||||
item.vendor = vendor.Name
|
||||
item.vendorIcon = vendor.Icon
|
||||
} else if pricing.OwnerBy != "" {
|
||||
item.vendor = pricing.OwnerBy
|
||||
}
|
||||
meta[pricing.ModelName] = item
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func modelMeta(modelName string, meta map[string]rankingModelMeta) rankingModelMeta {
|
||||
if item, ok := meta[modelName]; ok && item.vendor != "" {
|
||||
return item
|
||||
}
|
||||
return rankingModelMeta{vendor: rankingUnknownVendor}
|
||||
}
|
||||
|
||||
func buildRankedModels(totals []model.RankingQuotaTotal, totalTokens int64, previousRanks map[string]int, previousTokens map[string]int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedModel {
|
||||
rows := make([]RankedModel, 0, len(totals))
|
||||
for idx, item := range totals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
var previousRank *int
|
||||
if rank, ok := previousRanks[item.ModelName]; ok {
|
||||
rankCopy := rank
|
||||
previousRank = &rankCopy
|
||||
}
|
||||
growth := 0.0
|
||||
if showGrowth {
|
||||
growth = rankingGrowthPct(item.TotalTokens, previousTokens[item.ModelName])
|
||||
}
|
||||
rows = append(rows, RankedModel{
|
||||
Rank: idx + 1,
|
||||
PreviousRank: previousRank,
|
||||
ModelName: item.ModelName,
|
||||
Vendor: modelMeta.vendor,
|
||||
VendorIcon: modelMeta.vendorIcon,
|
||||
Category: "all",
|
||||
TotalTokens: item.TotalTokens,
|
||||
Share: rankingShare(item.TotalTokens, totalTokens),
|
||||
GrowthPct: growth,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func buildRankedVendors(currentTotals []model.RankingQuotaTotal, previousTotals []model.RankingQuotaTotal, totalTokens int64, meta map[string]rankingModelMeta, showGrowth bool) []RankedVendor {
|
||||
aggregates := make(map[string]*vendorAggregate)
|
||||
for _, item := range currentTotals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
agg := ensureVendorAggregate(aggregates, modelMeta)
|
||||
agg.totalTokens += item.TotalTokens
|
||||
agg.models[item.ModelName] = struct{}{}
|
||||
if item.TotalTokens > agg.topModelTokens {
|
||||
agg.topModel = item.ModelName
|
||||
agg.topModelTokens = item.TotalTokens
|
||||
}
|
||||
}
|
||||
for _, item := range previousTotals {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
agg := ensureVendorAggregate(aggregates, modelMeta)
|
||||
agg.previousTokens += item.TotalTokens
|
||||
}
|
||||
|
||||
rows := make([]RankedVendor, 0, len(aggregates))
|
||||
for _, agg := range aggregates {
|
||||
if agg.totalTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
growth := 0.0
|
||||
if showGrowth {
|
||||
growth = rankingGrowthPct(agg.totalTokens, agg.previousTokens)
|
||||
}
|
||||
rows = append(rows, RankedVendor{
|
||||
Vendor: agg.name,
|
||||
VendorIcon: agg.icon,
|
||||
TotalTokens: agg.totalTokens,
|
||||
Share: rankingShare(agg.totalTokens, totalTokens),
|
||||
GrowthPct: growth,
|
||||
ModelsCount: len(agg.models),
|
||||
TopModel: agg.topModel,
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].TotalTokens == rows[j].TotalTokens {
|
||||
return rows[i].Vendor < rows[j].Vendor
|
||||
}
|
||||
return rows[i].TotalTokens > rows[j].TotalTokens
|
||||
})
|
||||
for idx := range rows {
|
||||
rows[idx].Rank = idx + 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func ensureVendorAggregate(aggregates map[string]*vendorAggregate, meta rankingModelMeta) *vendorAggregate {
|
||||
name := meta.vendor
|
||||
if name == "" {
|
||||
name = rankingUnknownVendor
|
||||
}
|
||||
agg, ok := aggregates[name]
|
||||
if !ok {
|
||||
agg = &vendorAggregate{
|
||||
name: name,
|
||||
icon: meta.vendorIcon,
|
||||
models: make(map[string]struct{}),
|
||||
}
|
||||
aggregates[name] = agg
|
||||
}
|
||||
if agg.icon == "" && meta.vendorIcon != "" {
|
||||
agg.icon = meta.vendorIcon
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
func buildModelHistory(buckets []model.RankingQuotaBucket, totals []model.RankingQuotaTotal, meta map[string]rankingModelMeta, config rankingPeriodConfig) ModelHistorySeries {
|
||||
topModels := make(map[string]struct{})
|
||||
models := make([]ModelHistoryModel, 0, minInt(len(totals), rankingHistoryLimit)+1)
|
||||
otherTotal := int64(0)
|
||||
for idx, item := range totals {
|
||||
if idx < rankingHistoryLimit {
|
||||
topModels[item.ModelName] = struct{}{}
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
models = append(models, ModelHistoryModel{Name: item.ModelName, Vendor: modelMeta.vendor, Total: item.TotalTokens})
|
||||
continue
|
||||
}
|
||||
otherTotal += item.TotalTokens
|
||||
}
|
||||
if otherTotal > 0 {
|
||||
models = append(models, ModelHistoryModel{Name: rankingOthersLabel, Vendor: "Various", Total: otherTotal})
|
||||
}
|
||||
|
||||
bucketSet := make(map[int64]struct{})
|
||||
tokensByBucketAndModel := make(map[int64]map[string]int64)
|
||||
for _, item := range buckets {
|
||||
modelName := item.ModelName
|
||||
if _, ok := topModels[modelName]; !ok {
|
||||
modelName = rankingOthersLabel
|
||||
}
|
||||
bucketSet[item.Bucket] = struct{}{}
|
||||
if _, ok := tokensByBucketAndModel[item.Bucket]; !ok {
|
||||
tokensByBucketAndModel[item.Bucket] = make(map[string]int64)
|
||||
}
|
||||
tokensByBucketAndModel[item.Bucket][modelName] += item.Tokens
|
||||
}
|
||||
|
||||
sortedBuckets := sortedRankingBuckets(bucketSet)
|
||||
points := make([]ModelHistoryPoint, 0, len(sortedBuckets)*len(models))
|
||||
for _, bucket := range sortedBuckets {
|
||||
for _, historyModel := range models {
|
||||
tokens := tokensByBucketAndModel[bucket][historyModel.Name]
|
||||
if tokens <= 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, ModelHistoryPoint{
|
||||
Ts: rankingBucketTs(bucket),
|
||||
Label: rankingBucketLabel(bucket, config),
|
||||
Model: historyModel.Name,
|
||||
Vendor: historyModel.Vendor,
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ModelHistorySeries{
|
||||
Points: points,
|
||||
Models: models,
|
||||
Buckets: len(sortedBuckets),
|
||||
}
|
||||
}
|
||||
|
||||
func buildVendorShareHistory(buckets []model.RankingQuotaBucket, vendors []RankedVendor, totalTokens int64, meta map[string]rankingModelMeta, config rankingPeriodConfig) VendorShareSeries {
|
||||
topVendors := make(map[string]struct{})
|
||||
vendorRows := make([]VendorShareVendor, 0, minInt(len(vendors), rankingVendorLimit)+1)
|
||||
otherTotal := int64(0)
|
||||
for idx, vendor := range vendors {
|
||||
if idx < rankingVendorLimit {
|
||||
topVendors[vendor.Vendor] = struct{}{}
|
||||
vendorRows = append(vendorRows, VendorShareVendor{Name: vendor.Vendor, Total: vendor.TotalTokens, Share: vendor.Share})
|
||||
continue
|
||||
}
|
||||
otherTotal += vendor.TotalTokens
|
||||
}
|
||||
if otherTotal > 0 {
|
||||
vendorRows = append(vendorRows, VendorShareVendor{Name: rankingOthersLabel, Total: otherTotal, Share: rankingShare(otherTotal, totalTokens)})
|
||||
}
|
||||
|
||||
bucketSet := make(map[int64]struct{})
|
||||
tokensByBucketAndVendor := make(map[int64]map[string]int64)
|
||||
totalsByBucket := make(map[int64]int64)
|
||||
for _, item := range buckets {
|
||||
modelMeta := modelMeta(item.ModelName, meta)
|
||||
vendorName := modelMeta.vendor
|
||||
if _, ok := topVendors[vendorName]; !ok {
|
||||
vendorName = rankingOthersLabel
|
||||
}
|
||||
bucketSet[item.Bucket] = struct{}{}
|
||||
if _, ok := tokensByBucketAndVendor[item.Bucket]; !ok {
|
||||
tokensByBucketAndVendor[item.Bucket] = make(map[string]int64)
|
||||
}
|
||||
tokensByBucketAndVendor[item.Bucket][vendorName] += item.Tokens
|
||||
totalsByBucket[item.Bucket] += item.Tokens
|
||||
}
|
||||
|
||||
sortedBuckets := sortedRankingBuckets(bucketSet)
|
||||
points := make([]VendorSharePoint, 0, len(sortedBuckets)*len(vendorRows))
|
||||
for _, bucket := range sortedBuckets {
|
||||
for _, vendor := range vendorRows {
|
||||
tokens := tokensByBucketAndVendor[bucket][vendor.Name]
|
||||
if tokens <= 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, VendorSharePoint{
|
||||
Ts: rankingBucketTs(bucket),
|
||||
Label: rankingBucketLabel(bucket, config),
|
||||
Vendor: vendor.Name,
|
||||
Share: rankingShare(tokens, totalsByBucket[bucket]),
|
||||
Tokens: tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return VendorShareSeries{
|
||||
Points: points,
|
||||
Vendors: vendorRows,
|
||||
Buckets: len(sortedBuckets),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRankingMovers(models []RankedModel) ([]RankingMover, []RankingMover) {
|
||||
movers := make([]RankingMover, 0)
|
||||
droppers := make([]RankingMover, 0)
|
||||
for _, item := range models {
|
||||
if item.PreviousRank == nil {
|
||||
continue
|
||||
}
|
||||
delta := *item.PreviousRank - item.Rank
|
||||
if delta == 0 {
|
||||
continue
|
||||
}
|
||||
row := RankingMover{
|
||||
ModelName: item.ModelName,
|
||||
Vendor: item.Vendor,
|
||||
VendorIcon: item.VendorIcon,
|
||||
RankDelta: delta,
|
||||
CurrentRank: item.Rank,
|
||||
GrowthPct: item.GrowthPct,
|
||||
}
|
||||
if delta > 0 {
|
||||
movers = append(movers, row)
|
||||
} else {
|
||||
droppers = append(droppers, row)
|
||||
}
|
||||
}
|
||||
sort.Slice(movers, func(i, j int) bool {
|
||||
if movers[i].RankDelta == movers[j].RankDelta {
|
||||
return movers[i].GrowthPct > movers[j].GrowthPct
|
||||
}
|
||||
return movers[i].RankDelta > movers[j].RankDelta
|
||||
})
|
||||
sort.Slice(droppers, func(i, j int) bool {
|
||||
if droppers[i].RankDelta == droppers[j].RankDelta {
|
||||
return droppers[i].GrowthPct < droppers[j].GrowthPct
|
||||
}
|
||||
return droppers[i].RankDelta < droppers[j].RankDelta
|
||||
})
|
||||
return limitRankingMovers(movers, rankingMoverLimit), limitRankingMovers(droppers, rankingMoverLimit)
|
||||
}
|
||||
|
||||
func sortedRankingBuckets(bucketSet map[int64]struct{}) []int64 {
|
||||
buckets := make([]int64, 0, len(bucketSet))
|
||||
for bucket := range bucketSet {
|
||||
buckets = append(buckets, bucket)
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i] < buckets[j]
|
||||
})
|
||||
return buckets
|
||||
}
|
||||
|
||||
func rankingBucketTs(bucket int64) string {
|
||||
return time.Unix(bucket, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func rankingBucketLabel(bucket int64, config rankingPeriodConfig) string {
|
||||
return time.Unix(bucket, 0).Format(config.labelLayout)
|
||||
}
|
||||
|
||||
func rankingRankMap(totals []model.RankingQuotaTotal) map[string]int {
|
||||
ranks := make(map[string]int, len(totals))
|
||||
for idx, item := range totals {
|
||||
ranks[item.ModelName] = idx + 1
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
func rankingTokenMap(totals []model.RankingQuotaTotal) map[string]int64 {
|
||||
tokens := make(map[string]int64, len(totals))
|
||||
for _, item := range totals {
|
||||
tokens[item.ModelName] = item.TotalTokens
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func sumRankingTokens(totals []model.RankingQuotaTotal) int64 {
|
||||
total := int64(0)
|
||||
for _, item := range totals {
|
||||
total += item.TotalTokens
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func rankingShare(value int64, total int64) float64 {
|
||||
if total <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return roundRankingFloat(float64(value) / float64(total))
|
||||
}
|
||||
|
||||
func rankingGrowthPct(current int64, previous int64) float64 {
|
||||
if previous <= 0 {
|
||||
if current > 0 {
|
||||
return 100
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return roundRankingFloat((float64(current-previous) / float64(previous)) * 100)
|
||||
}
|
||||
|
||||
func roundRankingFloat(value float64) float64 {
|
||||
return math.Round(value*10000) / 10000
|
||||
}
|
||||
|
||||
func limitRankedModels(rows []RankedModel, limit int) []RankedModel {
|
||||
if limit <= 0 || len(rows) <= limit {
|
||||
return rows
|
||||
}
|
||||
return rows[:limit]
|
||||
}
|
||||
|
||||
func limitRankingMovers(rows []RankingMover, limit int) []RankingMover {
|
||||
if limit <= 0 || len(rows) <= limit {
|
||||
return rows
|
||||
}
|
||||
return rows[:limit]
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -471,4 +473,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
||||
Group: relayInfo.UsingGroup,
|
||||
Other: other,
|
||||
})
|
||||
gopool.Go(func() {
|
||||
perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package perf_metrics_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type PerfMetricsSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
FlushInterval int `json:"flush_interval"`
|
||||
BucketTime string `json:"bucket_time"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
|
||||
var perfMetricsSetting = PerfMetricsSetting{
|
||||
Enabled: true,
|
||||
FlushInterval: 5,
|
||||
BucketTime: "hour",
|
||||
RetentionDays: 0,
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("perf_metrics_setting", &perfMetricsSetting)
|
||||
}
|
||||
|
||||
func GetSetting() PerfMetricsSetting {
|
||||
return perfMetricsSetting
|
||||
}
|
||||
|
||||
func GetBucketSeconds() int64 {
|
||||
switch perfMetricsSetting.BucketTime {
|
||||
case "minute":
|
||||
return 60
|
||||
case "5min":
|
||||
return 300
|
||||
case "hour":
|
||||
return 3600
|
||||
default:
|
||||
return 3600
|
||||
}
|
||||
}
|
||||
|
||||
func GetFlushIntervalMinutes() int {
|
||||
if perfMetricsSetting.FlushInterval < 1 {
|
||||
return 1
|
||||
}
|
||||
return perfMetricsSetting.FlushInterval
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type ThemeSettings struct {
|
||||
Frontend string `json:"frontend"`
|
||||
}
|
||||
|
||||
var themeSettings = ThemeSettings{
|
||||
Frontend: "classic",
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("theme", &themeSettings)
|
||||
syncThemeToCommon()
|
||||
}
|
||||
|
||||
func syncThemeToCommon() {
|
||||
common.SetTheme(themeSettings.Frontend)
|
||||
}
|
||||
|
||||
func GetThemeSettings() *ThemeSettings {
|
||||
return &themeSettings
|
||||
}
|
||||
|
||||
// UpdateAndSyncTheme syncs the theme config to common after DB load.
|
||||
func UpdateAndSyncTheme() {
|
||||
syncThemeToCommon()
|
||||
}
|
||||
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../services/secureVerification';
|
||||
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||
|
||||
/**
|
||||
* 渠道密钥查看组件使用示例
|
||||
* 展示如何使用通用安全验证系统
|
||||
*/
|
||||
const ChannelKeyViewExample = ({ channelId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [keyData, setKeyData] = useState('');
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
|
||||
// 使用通用安全验证 Hook
|
||||
const {
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
} = useSecureVerification({
|
||||
onSuccess: (result) => {
|
||||
// 验证成功后处理结果
|
||||
if (result.success && result.data?.key) {
|
||||
setKeyData(result.data.key);
|
||||
setShowKeyModal(true);
|
||||
}
|
||||
},
|
||||
successMessage: t('密钥获取成功'),
|
||||
});
|
||||
|
||||
// 开始查看密钥流程
|
||||
const handleViewKey = async () => {
|
||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||
|
||||
await startVerification(apiCall, {
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 查看密钥按钮 */}
|
||||
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
|
||||
{/* 安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
verificationMethods={verificationMethods}
|
||||
verificationState={verificationState}
|
||||
onVerify={executeVerification}
|
||||
onCancel={cancelVerification}
|
||||
onCodeChange={setVerificationCode}
|
||||
onMethodSwitch={switchVerificationMethod}
|
||||
title={verificationState.title}
|
||||
description={verificationState.description}
|
||||
/>
|
||||
|
||||
{/* 密钥显示模态框 */}
|
||||
<Modal
|
||||
title={t('渠道密钥信息')}
|
||||
visible={showKeyModal}
|
||||
onCancel={() => setShowKeyModal(false)}
|
||||
footer={
|
||||
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
}
|
||||
width={700}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<ChannelKeyDisplay
|
||||
keyData={keyData}
|
||||
showSuccessIcon={true}
|
||||
successText={t('密钥获取成功')}
|
||||
showWarning={true}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyViewExample;
|
||||