Compare commits

...

85 Commits

Author SHA1 Message Date
IcedTangerine 9717af7dcd Merge pull request #1553 from feitianbubu/pr/add-jimeng-officail-api
feat: add jimeng video official api
2025-08-12 16:32:49 +08:00
CaIon 219ed0d1cf fix(database): improve MySQL Chinese character support validation 2025-08-12 16:31:00 +08:00
CaIon 9625ee2d55 fix(relay): remove unnecessary channel type check for BadRequest 2025-08-12 16:12:47 +08:00
Calcium-Ion 23d4100fe3 Merge pull request #1556 from QuantumNous/fix-register-mail-verifycode-waiting-time
fix: 注册时发送邮件验证码没有等待时间
2025-08-12 14:20:09 +08:00
CaIon 5b8382a6ab Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 14:13:34 +08:00
Calcium-Ion 9ec326714e Merge pull request #1569 from duyazhe/codex/cloudflare-responses
feat: add responses support for cloudflare
2025-08-12 14:13:14 +08:00
CaIon a423ee3dd1 feat(database): enhance MySQL support for Chinese characters
- Added a check for MySQL charset/collation to ensure compatibility with Chinese characters during database initialization.
- Updated SQLite busy timeout from 5000ms to 30000ms for improved performance.
- Removed commented-out PostgreSQL migration logic for clarity.
2025-08-12 14:12:11 +08:00
同語 f10d469b72 Update web_api.md 2025-08-12 11:15:32 +08:00
同語 fd6b6175bb Merge pull request #1563 from QAbot-zh/docs-update
docs(web_api): 修复 Markdown 表格格式
2025-08-12 11:14:35 +08:00
t0ng7u c59b331170 🚫 feat(web): add 403 Forbidden page and AdminRoute guard
- Add new Forbidden page at /forbidden (`web/src/pages/Forbidden/index.js`)
  - Use Semi-UI Empty with IllustrationNoAccess (250x250)
  - Update i18n description to: '您无权访问此页面,请联系管理员~'
  - Align visual style with existing 404 page
- Introduce `AdminRoute` in `web/src/helpers/auth.js`
  - Use `UserContext`/localStorage; redirect to `/forbidden` when `!user` or `user.role < 10`
- Protect console/admin routes with `AdminRoute` and register `/forbidden` in `web/src/App.js`
- Update `web/src/i18n/locales/en.json`
  - Add English translation for the new forbidden message
  - Remove legacy "没有权限" entry
- Lint passes; no runtime errors observed
2025-08-12 10:45:21 +08:00
t0ng7u 729593afae Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-12 10:22:13 +08:00
t0ng7u 782ad01496 🐛 fix: make ModelSelectModal panels collapsible and default to collapsed
- Switch Collapse from controlled (activeKey) to uncontrolled (defaultActiveKey) so user toggling works
- Add a stable key to reset Collapse state when tab/category changes
- Default all panels to collapsed via defaultActiveKey: []
- Preserve Panel itemKey for consistent behavior
- No linter errors introduced

Scope: web/src/components/table/channels/modals/ModelSelectModal.jsx
2025-08-12 10:22:00 +08:00
Seefs 4e2e6e6e86 Merge pull request #1570 from seefs001/fix/zhipu_v4_thinking
fix: zhipu_v4 thinking
2025-08-11 21:43:35 +08:00
nekohy 5478d2fb59 fix: zhipu_v4 thinking 2025-08-11 21:37:10 +08:00
CaIon 32978f9cd9 feat: Simplify response handling by returning raw response body directly 2025-08-11 20:07:24 +08:00
CaIon ce74e94fc7 feat: Refactor Gemini tools handling to support JSON raw message format 2025-08-11 19:48:04 +08:00
t0ng7u 7d3bf7c5bb 🍭 ui: change pricing page card view pt-4 to py-4 2025-08-11 19:11:58 +08:00
t0ng7u 472377bf8c 🍭 ui: change pricing page card view p-4 to px-4 2025-08-11 18:32:19 +08:00
t0ng7u 6c7a10ac7c ui: Add CSS ellipsis + Tooltip for SelectableButtonGroup; keep Tag intact
- Truncate long labels via pure CSS and always show full text in a Tooltip
- Ensure the right-side Tag is never truncated and remains fully visible
- Simplify implementation: remove overflow detection and ResizeObserver
- Use minimal markup with sbg-button/sbg-inner/sbg-label to enable shrinking
- Add global rules to allow `.semi-button-content` to shrink and ellipsize

Files:
- web/src/components/common/ui/SelectableButtonGroup.jsx
- web/src/index.css

No API changes; visuals improved and code complexity reduced.
2025-08-11 18:27:32 +08:00
t0ng7u 375e25221d 🤓 style(ui): remove the pricing page’s border style to reduce visual clutter 2025-08-11 17:44:12 +08:00
t0ng7u 2b6e3e8010 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-11 17:38:16 +08:00
CaIon a80ecb8896 feat: Refactor model handling to use UpstreamModelName for request processing 2025-08-11 17:32:58 +08:00
CaIon 37f0383941 feat: Update request URL handling for Claude relay format in adaptor #1557 2025-08-11 17:17:56 +08:00
CaIon c9b276c604 Merge remote-tracking branch 'origin/alpha' into alpha
# Conflicts:
#	relay/channel/openai/adaptor.go
2025-08-11 16:35:23 +08:00
Q.A.zh 6193c547d9 docs(web_api): 修复 Markdown 表格格式 2025-08-11 08:34:14 +00:00
CaIon be77f3d763 feat: Update Azure responses API version handling in adaptor 2025-08-11 16:34:07 +08:00
t0ng7u 337bb41588 💄 style: Use segmented renderer for billing types in Models table; keep Pricing view unchanged
Frontend
- Models table (model management):
  - Render billing types with the same segmented list component (renderLimitedItems) used by tags and endpoints
  - Display quota_types as an array with capped items (maxDisplay: 3) and graceful fallback for unknown types
- Pricing view (unchanged by request):
  - Revert to single-value quota_type rendering and sorter
  - Keep ratio display logic based on quota_type only

Files
- web/src/components/table/models/ModelsColumnDefs.js
- web/src/components/table/model-pricing/view/table/PricingTableColumns.js

Notes
- This commit only adjusts the model management UI rendering; pricing views remain as-is
2025-08-11 15:53:55 +08:00
duyazhe 8df5a45805 feat: add responses support for cloudflare 2025-08-11 15:29:16 +08:00
t0ng7u ec006d3a67 🚀 perf: optimize model management APIs, unify pricing types as array, and remove redundancies
Backend
- Add GetBoundChannelsByModelsMap to batch-fetch bound channels via a single JOIN (Distinct), compatible with SQLite/MySQL/PostgreSQL
- Replace per-record enrichment with a single-pass enrichModels to avoid N+1 queries; compute unions for prefix/suffix/contains matches in memory
- Change Model.QuotaType to QuotaTypes []int and expose quota_types in responses
- Add GetModelQuotaTypes for cached O(1) lookups; exact models return a single-element array
- Sort quota_types for stable output order
- Remove unused code: GetModelByName, GetBoundChannels, GetBoundChannelsForModels, FindModelByNameWithRule, buildPrefixes, buildSuffixes
- Clean up redundant comments, keeping concise and readable code

Frontend
- Models table: switch to quota_types, render multiple billing modes ([0], [1], [0,1], future values supported)
- Pricing table: switch to quota_types; ratio display now checks quota_types.includes(0); array rendering for billing tags

Compatibility
- SQL uses standard JOIN/IN/Distinct; works across SQLite/MySQL/PostgreSQL
- Lint passes; no DB schema changes (quota_types is a JSON response field only)

Breaking Change
- API field renamed: quota_type -> quota_types (array). Update clients accordingly.
2025-08-11 14:40:01 +08:00
t0ng7u 067f3ce9ed 🔧 chore(db): drop legacy single-column UNIQUE indexes to prevent duplicate-key errors after soft-delete
Why
Previous versions created single-column UNIQUE constraints (`models.model_name`, `vendors.name`).
After introducing composite indexes on `(model_name, deleted_at)` and `(name, deleted_at)` for soft-delete support, those legacy constraints could still exist in user databases.
When a record was soft-deleted and re-inserted with the same name, MySQL raised `Error 1062 … for key 'models.model_name'`.

What
• In `migrateDB` and `migrateDBFast` paths of `model/main.go`, proactively drop:
  – `models.uk_model_name` and fallback `models.model_name`
  – `vendors.uk_vendor_name` and fallback `vendors.name`
• Keeps existing helper `dropIndexIfExists` to ensure the operation is MySQL-only and error-free when indexes are already absent.

Result
Startup migration now removes every possible legacy UNIQUE index, ensuring composite index strategy works correctly.
Users can soft-delete and recreate models/vendors with identical names without hitting duplicate-entry errors.
2025-08-11 01:25:13 +08:00
t0ng7u 649fa84231 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 23:18:16 +08:00
creamlike1024 46cd3f4071 feat(middleware): redis atomic incr, show waiting time 2025-08-10 23:18:09 +08:00
t0ng7u 8d54f86261 🤓 chore: format code file 2025-08-10 23:17:04 +08:00
t0ng7u 0bcd7388f4 🏎️ perf: optimize aggregated model look-ups by batching bound-channel queries
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `GetBoundChannelsForModels([]string)` to retrieve channels for multiple models in a single SQL (`IN (?)`) and deduplicate with `GROUP BY`.

   • `controller/model_meta.go`
     – In non-exact `fillModelExtra`:
       – Remove per-model `GetBoundChannels` calls.
       – Collect matched model names, then call `GetBoundChannelsForModels` once and merge results into `channelSet`.
       – Minor cleanup on loop logic; channel aggregation now happens after quota/group/endpoint processing.

Impact
------
• Eliminates N+1 query pattern for prefix/suffix/contains rules.
• Reduces DB round-trips from *N + 1* to **1**, markedly speeding up the model-management list load.
• Keeps existing `GetBoundChannels` API intact for single-model scenarios; no breaking changes.
2025-08-10 23:11:35 +08:00
Seefs 3b6d0d0291 Merge pull request #1547 from seefs001/feature/model_list
 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling
2025-08-10 22:57:20 +08:00
t0ng7u 51f7f89190 🔍 feat: Show matched model names & counts for non-exact model rules
Summary
-------
1. **Backend**
   • `model/model_meta.go`
     – Add `MatchedModels []string` and `MatchedCount int` (ignored by GORM) to expose matching details in API responses.
   • `controller/model_meta.go`
     – When processing prefix/suffix/contains rules in `fillModelExtra`, collect every matched model name, fill `MatchedModels`, and calculate `MatchedCount`.

2. **Frontend**
   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Import `Tooltip`.
     – Enhance `renderNameRule` to:
       – Display tag text like “前缀 5个模型” for non-exact rules.
       – Show a tooltip listing all matched model names on hover.

Impact
------
Users now see the total number of concrete models aggregated under each prefix/suffix/contains rule and can inspect the exact list via tooltip, improving transparency in model management.
2025-08-10 21:32:18 +08:00
creamlike1024 ff2e0dbc26 feat(middleware): add email verification rate limit 2025-08-10 21:22:53 +08:00
t0ng7u e1d51fd169 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-10 21:14:47 +08:00
t0ng7u 85ecfd7062 feat: enhance model billing aggregation & UI display for unknown quota type
Summary
-------
1. **Backend**
   • `controller/model_meta.go`
     – For prefix/suffix/contains rules, aggregate endpoints, bound channels, enable groups, and quota types across all matched models.
     – When mixed billing types are detected, return `quota_type = -1` (unknown) instead of defaulting to volume-based.

2. **Frontend**
   • `web/src/helpers/utils.js`
     – `calculateModelPrice` now handles `quota_type = -1`, returning placeholder `'-'`.

   • `web/src/components/table/model-pricing/view/card/PricingCardView.jsx`
     – Billing tag logic updated: displays “按次计费” (times), “按量计费” (volume), or `'-'` for unknown.

   • `web/src/components/table/model-pricing/view/table/PricingTableColumns.js`
     – `renderQuotaType` shows “未知” for unknown billing type.

   • `web/src/components/table/models/ModelsColumnDefs.js`
     – Unified `renderQuotaType` to return `'-'` when type is unknown.

   • `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx`
     – Group price table honors unknown billing type; pricing columns show `'-'` and neutral tag color.

3. **Utilities**
   • Added safe fallback colours/tags for unknown billing type across affected components.

Impact
------
• Ensures correct data aggregation for non-exact model matches.
• Prevents UI from implying volume billing when actual type is ambiguous.
• Provides consistent placeholder display (`'-'` or “未知”) across cards, tables and modals.

No breaking API changes; frontend gracefully handles legacy values.
2025-08-10 21:09:49 +08:00
CaIon 7ffbff88f1 feat: Update request URL handling for Azure responses based on base URL 2025-08-10 21:09:16 +08:00
CaIon 8907e5cf6d feat: Add ChannelOtherSettings to manage additional channel configurations 2025-08-10 20:21:30 +08:00
creamlike1024 8217e694f8 fix: 注册时发送邮件验证码没有等待时间 2025-08-10 19:15:26 +08:00
t0ng7u 2cabe4f6ac 🔠 refactor: refine group label formatting in price info
Summary:
• Updated `helpers/utils.js` to display the “group” label without a colon, ensuring consistent typography with other price elements.

Details:
1. `formatPriceInfo`
   – Changed `{t('分组')}:` to `{t('分组')}` for a cleaner look.
   – Keeps spacing intact between label and selected group name.
2. No functional impact; purely visual polish.
2025-08-10 17:17:49 +08:00
feitianbubu 73609f50d0 feat: add jimeng video official api 2025-08-10 16:54:44 +08:00
t0ng7u ca85d224f1 📱 style: Hide vendor introduction on mobile devices
Summary:
• Updated `PricingTopSection.jsx` to conditionally render `PricingVendorIntroWithSkeleton` only when `isMobile` is false.

Details:
1. Wrapped vendor-intro block in `!isMobile` check, preventing unnecessary content on small screens.
2. Kept desktop experience unchanged; no impact on other features.
3. Lint check passed with no new issues.

Result:
Cleaner mobile UI with improved performance and visual focus.
2025-08-10 14:10:50 +08:00
t0ng7u b6dd701ce8 feat: Add tag-based filtering & refactor filter counts logic
Overview:
• Introduced a new “Model Tag” filter across pricing screens
• Refactored `usePricingFilterCounts` to eliminate duplicated logic
• Improved tag handling to be case-insensitive and deduplicated
• Extended utilities to reset & persist the new filter

Details:
1. Added `filterTag` state to `useModelPricingData` and integrated it into all filtering paths.
2. Created reusable `PricingTags` component using `SelectableButtonGroup`.
3. Incorporated tag filter into `PricingSidebar` and mobile `PricingFilterModal`, including reset support.
4. Enhanced `resetPricingFilters` (helpers/utils) to restore tag filter defaults.
5. Refactored `usePricingFilterCounts.js`:
   • Centralized predicate `matchesFilters` to remove redundancy
   • Normalized tag parsing via `normalizeTags` helper
   • Memoized model subsets with concise filter calls
6. Updated lints – zero errors after refactor.

Result:
Users can now filter models by custom tags with consistent UX, and internal logic is cleaner, faster, and easier to extend.
2025-08-10 14:05:25 +08:00
t0ng7u 7130ec8e32 🐛 fix(PricingCardView): hide placeholder dash when no custom tags
Previously, the card view displayed a “-” whenever a model had no custom tags,
because `renderLimitedItems` returned a dash for an empty array.
Now the function is only invoked when `customTags.length > 0`, removing the
unwanted placeholder and keeping the UI clean.

File affected:
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
2025-08-10 13:45:16 +08:00
t0ng7u c79b6cea32 fix: prevent model name flicker when closing SideSheet
- Delay clearing selectedModel until SideSheet close animation completes
- Prevents brief display of 'AI/Unknown Model' text during closing transition
- Improves user experience by eliminating visual flicker
- Uses 300ms timeout matching Semi UI default animation duration
2025-08-10 13:41:19 +08:00
CaIon 54f470bc46 feat: Enhance EditChannelModal with JSONEditor key updates and input reset
- Added unique keys for JSONEditor components to ensure proper re-rendering based on channelId.
- Implemented input reset to clear previous JSON field values when the modal is opened.
2025-08-10 12:22:18 +08:00
t0ng7u 1ea54eb2ed 🖼️ chore: format code file 2025-08-10 12:11:31 +08:00
nekohy 4cf1ffa801 feat: Enhance model listing and retrieval with support for Anthropic and Gemini models; refactor routes for better API key handling 2025-08-10 11:44:38 +08:00
t0ng7u 750dea9de7 🐛 fix(model): allow zero-value updates so tags/description can be cleared 2025-08-10 11:03:39 +08:00
Calcium-Ion ac9a1ae876 Merge pull request #1496 from Bliod-Cook/feat-linuxdo-minumum-trust-level
feat: allow admin to restrict the minimum linuxdo trust level to register
2025-08-10 10:27:40 +08:00
Calcium-Ion c59253ea6e Merge pull request #1537 from RedwindA/feat/support-native-gemini-embedding
feat: 支持原生Gemini Embedding格式
2025-08-10 10:26:46 +08:00
Calcium-Ion 571301adfd Merge pull request #1541 from QuantumNous/fluent-read
feat: added "流畅阅读" (FluentRead) as a new chat provider option.
2025-08-10 10:26:22 +08:00
t0ng7u abee65c1db feat: Add model icon support across backend and UI; prefer model icon over vendor; add icon column in Models table
Backend:
- Model: Add `icon` field to `model.Model` (gorm: varchar(128)); auto-migrated via GORM.
- Pricing API: Extend `model.Pricing` with `icon` and populate from model meta in `GetPricing()`.

Frontend:
- EditModelModal: Add `icon` input (with @lobehub/icons helper link); wire into init/load/submit flows.
- ModelHeader / PricingCardView: Prefer rendering `model.icon`; fallback to `vendor_icon`; final fallback to initials avatar.
- Models table: Add leading “Icon” column, rendering `model.icon` or `vendor` icon via `getLobeHubIcon`.

Notes:
- Backward-compatible. Existing data without `icon` remain unaffected.
- No manual SQL needed; column is added by AutoMigrate.

Affected files:
- model/model_meta.go
- model/pricing.go
- web/src/components/table/models/modals/EditModelModal.jsx
- web/src/components/table/model-pricing/modal/components/ModelHeader.jsx
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
- web/src/components/table/models/ModelsColumnDefs.js
2025-08-10 01:38:59 +08:00
t0ng7u 734ef7ddac feat: Support dot‑chained props for LobeHub icons
- render.js: Enhance getLobeHubIcon to parse dot‑chained props, e.g.:
  - OpenAI.Avatar.type={'platform'}
  - OpenRouter.Avatar.shape={'square'}
  - Parses booleans/numbers/strings and {…} wrappers; keeps the 2nd arg `size` unless overridden by chain props. Backward compatible.
- EditVendorModal.jsx: Update UI copy — simplify placeholder; document chain‑parameter examples in extra text with doc link.
- en.json: Fix invalid escape sequences in the new i18n string to satisfy linter.

No behavioral changes outside icon rendering; lints pass.
2025-08-10 01:18:36 +08:00
t0ng7u 58af8ed688 🍎 chore: modify pagination pageSizeOpts 2025-08-10 00:58:17 +08:00
t0ng7u 8a3107448b 🎨 refactor: MultiKeyManageModal: cleaner stats UI, remove chart, integrate toolbar/pagination, and improve UX
- Replace custom dots with Semi Badge types (success/danger/warning); add compact Progress bars
- Remove pie chart and related deps/config; move total key count and mode tags into the modal title
- Rework header using Row/Col; three equal stat cards (enabled/manual-disabled/auto-disabled)
- Integrate toolbar into Table title; wrap content with Card; use Table’s native empty state
- Make “Enable All” conditional (hidden when all keys are enabled), mirroring “Disable All”
- Unify numeric typography (current/total same size) for better readability
- Default page size set to 10; fallback to 10 when backend page_size is absent; page-size options: 10/20/50/100
- Cleanup imports and dead code (remove VChart and pie-spec logic)
- Minor spacing polish (extra bottom margin before table), no footer buttons
2025-08-10 00:55:18 +08:00
t0ng7u a0f48f5a30 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 21:41:33 +08:00
t0ng7u 2177620e68 💄 style(ui): replace inline gradients with reusable pastel blur balls; improve dark mode
- Introduce a global CSS utility `with-pastel-balls` in `web/src/index.css`, rendering pastel “blur balls” via ::before with CSS variables (`--pb1..--pb4`, `--pb-opacity`, `--pb-blur`) for easy theming.
- Apply the utility to pricing header cards and skeletons:
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx`
  - `web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx`
- Remove per-component inline `linear-gradient(...)` backgrounds and redundant absolute-positioned decoration nodes to reduce duplication.
- Dark mode:
  - Keep the same pastel palette (pink/lavender/mint/peach).
  - Increase visibility with `--pb-opacity: 0.36`, `--pb-blur: 65px`, and `mix-blend-mode: screen`.
- No functional logic changes; UI-only. Lint passes.

Affected files:
- web/src/index.css
- web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
- web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
2025-08-09 21:40:32 +08:00
CaIon 991a09f47a feat: update Fluent Read link handling in sidebar to improve chat item filtering 2025-08-09 21:06:25 +08:00
CaIon 4abe75faed feat: remove custom JSON marshaling for Message struct 2025-08-09 21:04:49 +08:00
CaIon 5a3f011ac2 feat: enhance JSON marshaling for Message and skip Fluent Read links in chat items 2025-08-09 20:58:28 +08:00
CaIon 4bbdc8e2b2 feat: add verbosity field to OpenAI request #1540 2025-08-09 20:12:27 +08:00
CaIon 4c0c9ffd0a feat: add AWS invoke error handling and new error code 2025-08-09 19:26:41 +08:00
CaIon 20575e798a feat: improve FluentRead notification handling and user prompts 2025-08-09 18:37:08 +08:00
RedwindA f5a52bc5b5 feat: update dto for embeddings 2025-08-09 18:31:56 +08:00
CaIon a5b4c2f7b5 feat: add FluentRead support in chat configuration 2025-08-09 18:26:45 +08:00
RedwindA 1dcb9a01ce Merge branch 'alpha' into 'feat/support-native-gemini-embedding' 2025-08-09 18:05:11 +08:00
CaIon 8e52b42c0a feat: update channel label to indicate deprecation and suggest alternative 2025-08-09 17:51:54 +08:00
CaIon f2dbdbc445 feat: update relay-text to conditionally include usage based on StreamOptions #696 2025-08-09 17:51:49 +08:00
CaIon 8c4f0e6992 Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 17:02:20 +08:00
CaIon 2fb3e265f6 feat: enhance TokensPage and useTokensData to support Fluent integration and notifications 2025-08-09 17:02:06 +08:00
t0ng7u 2da040f74c 🧹 Refactor: remove redundant code and simplify renderers
- Users table (UsersColumnDefs.js):
  - Remove unused quota calculations from the status renderer
  - Keep status Tag minimal; tooltip shows request count only
  - No functional changes

- Tokens table (TokensColumnDefs.js):
  - Simplify chats menu parsing from localStorage; remove redundant flags/loops
  - Remove unused variables and console statements
  - Keep error handling via showError; preserve existing operations behavior

- General:
  - Codebase tidying only; no UI/logic changes beyond cleanup
  - Lint passes successfully
2025-08-09 16:51:09 +08:00
t0ng7u 2757d555c7 style(ui): Replace switches with buttons; add quota column with Popover; cleanup
- Tokens/Users tables:
  - Replaced status Switch with explicit Enable/Disable buttons in the operation column
  - Unified button styles with Channels/Models (Disable: danger + small; Enable: default + small)
  - Status column now shows a small Tag only; standardized labels (Enabled/Disabled/etc.); removed usage info

- New "Remaining/Total Quota" column:
  - Wrapped in a white Tag; shows Remaining/Total with a progress bar
  - Replaced Tooltip with Popover; contents use Typography.Paragraph with copyable values
  - Copyable content excludes percentages (only numeric quota values are copied)
  - Added padding to Popover content for better readability

- Tokens specifics:
  - For unlimited quota, show a white Tag "Unlimited quota" with a Popover that displays copyable "Used quota"

- Cleanup:
  - Removed Switch imports/handlers and unused code paths
  - Eliminated console logs and redundant flags; simplified chats parsing
  - Removed quota calculations from status renderers

Files:
- web/src/components/table/tokens/TokensColumnDefs.js
- web/src/components/table/users/UsersColumnDefs.js
2025-08-09 16:47:14 +08:00
t0ng7u 1dd1ef862f 🐛 fix(db): allow re-adding models & vendors after soft delete; add safe index cleanup
Replace legacy single-column unique indexes with composite unique indexes on
(name, deleted_at) and introduce a safe index drop utility to eliminate
duplicate-key errors and noisy MySQL 1091 warnings.

WHAT
• model/model_meta.go
  - Model.ModelName  → `uniqueIndex:uk_model_name,priority:1`
  - Model.DeletedAt  → `index; uniqueIndex:uk_model_name,priority:2`
• model/vendor_meta.go
  - Vendor.Name      → `uniqueIndex:uk_vendor_name,priority:1`
  - Vendor.DeletedAt → `index; uniqueIndex:uk_vendor_name,priority:2`
• model/main.go
  - Add `dropIndexIfExists(table, index)`:
    • Checks `information_schema.statistics`
    • Drops index only when present (avoids Error 1091)
  - Invoke helper in `migrateDB` & `migrateDBFast`
  - Remove direct `ALTER TABLE … DROP INDEX …` calls

WHY
• Users received `Error 1062 (23000)` when re-creating a soft-deleted
  model/vendor because the old unique index enforced uniqueness on name alone.
• Directly dropping nonexistent indexes caused MySQL `Error 1091` noise.

HOW
• Composite unique indexes `(model_name, deleted_at)` / `(name, deleted_at)`
  respect GORM soft deletes.
• Safe helper ensures idempotent migrations across environments.

RESULT
• Users can now delete and re-add the same model or vendor without manual SQL.
• Startup migration runs quietly across MySQL, PostgreSQL, and SQLite.
• No behavior changes for existing data beyond index updates.

TEST
1. Add model “deepseek-chat” → delete (soft) → re-add → success.
2. Add vendor “DeepSeek”     → delete (soft) → re-add → success.
3. Restart service twice → no duplicate key or 1091 errors.
2025-08-09 15:44:08 +08:00
t0ng7u 4886f86cc8 style(JSONEditor): add AGPL-3.0 license header, clean imports & refine Banner UI
* Added full GNU Affero General Public License v3 header at the top of `JSONEditor.js`.
* Removed unused `IconCode` and `IconRefresh` imports to eliminate dead code.
* Set `closeIcon={null}` and applied `!rounded-md` class for `Banner`, improving visual consistency and preventing unintended dismissal.
* Normalized whitespace and line-breaks for better readability and lint compliance.
2025-08-09 14:08:28 +08:00
同語 a08e027bde feat: optimized JSONEditor in duplicate key handling
Merge pull request #1534 from HynoR/feat/je
2025-08-09 13:22:16 +08:00
t0ng7u 92a979751e Merge remote-tracking branch 'origin/alpha' into alpha 2025-08-09 13:10:20 +08:00
t0ng7u e1796b92cd 🐛 fix(db): allow re-adding models/vendors after soft delete via composite unique indexes
Ensure models and vendors can be re-created after soft deletion by switching to composite unique indexes on (name, deleted_at) and cleaning up legacy single-column unique indexes on MySQL.

Why
- MySQL raised 1062 duplicate key errors when re-adding a soft-deleted model/vendor because the legacy unique index enforced uniqueness on the name column alone (uk_model_name / uk_vendor_name), despite soft deletes.
- Users encountered errors such as:
  - Error 1062 (23000): Duplicate entry 'deepseek-chat' for key 'models.uk_model_name'
  - Error 1062 (23000): Duplicate entry 'DeepSeek' for key 'vendors.uk_vendor_name'

How
- Model indices:
  - model/model_meta.go:
    - Model.ModelName → gorm: uniqueIndex:uk_model_name,priority:1
    - Model.DeletedAt → gorm: index; uniqueIndex:uk_model_name,priority:2
- Vendor indices:
  - model/vendor_meta.go:
    - Vendor.Name → gorm: uniqueIndex:uk_vendor_name,priority:1
    - Vendor.DeletedAt → gorm: index; uniqueIndex:uk_vendor_name,priority:2
- Migration (automatic, idempotent):
  - model/main.go (migrateDB, migrateDBFast):
    - On MySQL, drop legacy single-column unique indexes if present:
      - ALTER TABLE models DROP INDEX uk_model_name;
      - ALTER TABLE vendors DROP INDEX uk_vendor_name;
    - Then run AutoMigrate to create composite unique indexes.
  - Missing-index errors are ignored to keep the migration safe to run multiple times.

Result
- Users can delete and re-add the same model/vendor name without manual SQL.
- Migration runs automatically at startup; no user action required.
- PostgreSQL and SQLite remain unaffected.

Files changed
- model/model_meta.go
- model/vendor_meta.go
- model/main.go (migrateDB, migrateDBFast)

Testing
- Create model "deepseek-chat" → delete (soft) → re-create → succeeds.
- Create vendor "DeepSeek" → delete (soft) → re-create → succeeds.

Backward compatibility
- Data remains intact; only index definitions are updated.
- Behavior is unchanged except for fixing the uniqueness constraint with soft deletes.
2025-08-09 13:07:57 +08:00
RedwindA 74057324b2 fix typo; add ParamOverride for Gemini Embedding 2025-08-09 01:07:48 +08:00
RedwindA 8127ad9929 feat: support native Gemini Embedding 2025-08-09 00:27:33 +08:00
HynoR a1b796b7ea feat: optimized Json Visual Editor(JSONEditor) when detected duplicate key 2025-08-08 19:00:02 +08:00
Bliod-Cook 3ed146f239 feat: allow admin to restrict the minimum linuxdo trust level to register 2025-08-04 17:19:38 +08:00
89 changed files with 3638 additions and 1929 deletions
+1
View File
@@ -83,6 +83,7 @@ var GitHubClientId = ""
var GitHubClientSecret = ""
var LinuxDOClientId = ""
var LinuxDOClientSecret = ""
var LinuxDOMinimumTrustLevel = 0
var WeChatServerAddress = ""
var WeChatServerToken = ""
+1 -1
View File
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
var UsingMySQL = false
var UsingClickHouse = false
var SQLitePath = "one-api.db?_busy_timeout=5000"
var SQLitePath = "one-api.db?_busy_timeout=30000"
+10 -10
View File
@@ -11,22 +11,22 @@ import "one-api/constant"
// 例如:{"path":"/v1/chat/completions","method":"POST"}
type EndpointInfo struct {
Path string `json:"path"`
Method string `json:"method"`
Path string `json:"path"`
Method string `json:"method"`
}
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
}
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
info, ok := defaultEndpointInfoMap[et]
return info, ok
info, ok := defaultEndpointInfoMap[et]
return info, ok
}
+1
View File
@@ -22,6 +22,7 @@ const (
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
+19 -11
View File
@@ -220,21 +220,29 @@ func LinuxdoOAuth(c *gin.Context) {
}
} else {
if common.RegisterEnabled {
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
user.DisplayName = linuxdoUser.Name
user.Role = common.RoleCommonUser
user.Status = common.UserStatusEnabled
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))
}
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
}
if err := user.Insert(inviterId); err != nil {
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": err.Error(),
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
})
return
}
+41 -40
View File
@@ -41,46 +41,47 @@ func GetStatus(c *gin.Context) {
cs := console_setting.GetConsoleSetting()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
"min_topup": setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"pay_methods": setting.PayMethods,
"usd_exchange_rate": setting.USDExchangeRate,
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": setting.ServerAddress,
"price": setting.Price,
"stripe_unit_price": setting.StripeUnitPrice,
"min_topup": setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
"quota_per_unit": common.QuotaPerUnit,
"display_in_currency": common.DisplayInCurrencyEnabled,
"enable_batch_update": common.BatchUpdateEnabled,
"enable_drawing": common.DrawingEnabled,
"enable_task": common.TaskEnabled,
"enable_data_export": common.DataExportEnabled,
"data_export_default_time": common.DataExportDefaultTime,
"default_collapse_sidebar": common.DefaultCollapseSidebar,
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"mj_notify_enabled": setting.MjNotifyEnabled,
"chats": setting.Chats,
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"pay_methods": setting.PayMethods,
"usd_exchange_rate": setting.USDExchangeRate,
// 面板启用开关
"api_info_enabled": cs.ApiInfoEnabled,
+15 -15
View File
@@ -1,27 +1,27 @@
package controller
import (
"net/http"
"one-api/model"
"net/http"
"one-api/model"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
// GetMissingModels returns the list of model names that are referenced by channels
// but do not have corresponding records in the models meta table.
// This helps administrators quickly discover models that need configuration.
func GetMissingModels(c *gin.Context) {
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": missing,
})
}
+49 -7
View File
@@ -16,6 +16,7 @@ import (
"one-api/relay/channel/moonshot"
relaycommon "one-api/relay/common"
"one-api/setting"
"time"
)
// https://platform.openai.com/docs/api-reference/models/list
@@ -102,7 +103,7 @@ func init() {
})
}
func ListModels(c *gin.Context) {
func ListModels(c *gin.Context, modelType int) {
userOpenAiModels := make([]dto.OpenAIModels, 0)
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
@@ -171,10 +172,41 @@ func ListModels(c *gin.Context) {
}
}
}
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
})
switch modelType {
case constant.ChannelTypeAnthropic:
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
useranthropicModels[i] = dto.AnthropicModel{
ID: model.Id,
CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
DisplayName: model.Id,
Type: "model",
}
}
c.JSON(200, gin.H{
"data": useranthropicModels,
"first_id": useranthropicModels[0].ID,
"has_more": false,
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
})
case constant.ChannelTypeGemini:
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
for i, model := range userOpenAiModels {
userGeminiModels[i] = dto.GeminiModel{
Name: model.Id,
DisplayName: model.Id,
}
}
c.JSON(200, gin.H{
"models": userGeminiModels,
"nextPageToken": nil,
})
default:
c.JSON(200, gin.H{
"success": true,
"data": userOpenAiModels,
})
}
}
func ChannelListModels(c *gin.Context) {
@@ -198,10 +230,20 @@ func EnabledListModels(c *gin.Context) {
})
}
func RetrieveModel(c *gin.Context) {
func RetrieveModel(c *gin.Context, modelType int) {
modelId := c.Param("model")
if aiModel, ok := openAIModelsMap[modelId]; ok {
c.JSON(200, aiModel)
switch modelType {
case constant.ChannelTypeAnthropic:
c.JSON(200, dto.AnthropicModel{
ID: aiModel.Id,
CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
DisplayName: aiModel.Id,
Type: "model",
})
default:
c.JSON(200, aiModel)
}
} else {
openAIError := dto.OpenAIError{
Message: fmt.Sprintf("The model '%s' does not exist", modelId),
+289 -137
View File
@@ -1,178 +1,330 @@
package controller
import (
"encoding/json"
"strconv"
"encoding/json"
"sort"
"strconv"
"strings"
"one-api/common"
"one-api/model"
"one-api/common"
"one-api/constant"
"one-api/model"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
// GetAllModelsMeta 获取模型列表(分页)
func GetAllModelsMeta(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 填充附加字段
for _, m := range modelsMeta {
fillModelExtra(m)
}
var total int64
model.DB.Model(&model.Model{}).Count(&total)
pageInfo := common.GetPageQuery(c)
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
var total int64
model.DB.Model(&model.Model{}).Count(&total)
// 统计供应商计数(全部数据,不受分页影响)
vendorCounts, _ := model.GetVendorModelCounts()
// 统计供应商计数(全部数据,不受分页影响)
vendorCounts, _ := model.GetVendorModelCounts()
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, gin.H{
"items": modelsMeta,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"vendor_counts": vendorCounts,
})
}
// SearchModelsMeta 搜索模型列表
func SearchModelsMeta(c *gin.Context) {
keyword := c.Query("keyword")
vendor := c.Query("vendor")
pageInfo := common.GetPageQuery(c)
keyword := c.Query("keyword")
vendor := c.Query("vendor")
pageInfo := common.GetPageQuery(c)
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
for _, m := range modelsMeta {
fillModelExtra(m)
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, pageInfo)
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
// 批量填充附加字段,提升列表接口性能
enrichModels(modelsMeta)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(modelsMeta)
common.ApiSuccess(c, pageInfo)
}
// GetModelMeta 根据 ID 获取单条模型信息
func GetModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
fillModelExtra(&m)
common.ApiSuccess(c, &m)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
var m model.Model
if err := model.DB.First(&m, id).Error; err != nil {
common.ApiError(c, err)
return
}
enrichModels([]*model.Model{&m})
common.ApiSuccess(c, &m)
}
// CreateModelMeta 新建模型
func CreateModelMeta(c *gin.Context) {
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.ModelName == "" {
common.ApiErrorMsg(c, "模型名称不能为空")
return
}
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
if err := m.Insert(); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// UpdateModelMeta 更新模型
func UpdateModelMeta(c *gin.Context) {
statusOnly := c.Query("status_only") == "true"
statusOnly := c.Query("status_only") == "true"
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
return
}
var m model.Model
if err := c.ShouldBindJSON(&m); err != nil {
common.ApiError(c, err)
return
}
if m.Id == 0 {
common.ApiErrorMsg(c, "缺少模型 ID")
return
}
if statusOnly {
// 只更新状态,防止误清空其他字段
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
common.ApiError(c, err)
return
}
} else {
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if statusOnly {
// 只更新状态,防止误清空其他字段
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
common.ApiError(c, err)
return
}
} else {
// 名称冲突检查
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "模型名称已存在")
return
}
if err := m.Update(); err != nil {
common.ApiError(c, err)
return
}
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
if err := m.Update(); err != nil {
common.ApiError(c, err)
return
}
}
model.RefreshPricing()
common.ApiSuccess(c, &m)
}
// DeleteModelMeta 删除模型
func DeleteModelMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, nil)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, nil)
}
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
func fillModelExtra(m *model.Model) {
if m.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(m.ModelName)
if b, err := json.Marshal(eps); err == nil {
m.Endpoints = string(b)
}
}
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
m.BoundChannels = channels
}
// 填充启用分组
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
// 填充计费类型
m.QuotaType = model.GetModelQuotaType(m.ModelName)
// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询
func enrichModels(models []*model.Model) {
if len(models) == 0 {
return
}
// 1) 拆分精确与规则匹配
exactNames := make([]string, 0)
exactIdx := make(map[string][]int) // modelName -> indices in models
ruleIndices := make([]int, 0)
for i, m := range models {
if m == nil {
continue
}
if m.NameRule == model.NameRuleExact {
exactNames = append(exactNames, m.ModelName)
exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i)
} else {
ruleIndices = append(ruleIndices, i)
}
}
// 2) 批量查询精确模型的绑定渠道
channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames)
// 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存
for name, indices := range exactIdx {
chs := channelsByModel[name]
for _, idx := range indices {
mm := models[idx]
if mm.Endpoints == "" {
eps := model.GetModelSupportEndpointTypes(mm.ModelName)
if b, err := json.Marshal(eps); err == nil {
mm.Endpoints = string(b)
}
}
mm.BoundChannels = chs
mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName)
mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName)
}
}
if len(ruleIndices) == 0 {
return
}
// 4) 一次性读取定价缓存,内存匹配所有规则模型
pricings := model.GetPricing()
// 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合
matchedNamesByIdx := make(map[int][]string)
endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{})
groupSetByIdx := make(map[int]map[string]struct{})
quotaSetByIdx := make(map[int]map[int]struct{})
for _, p := range pricings {
for _, idx := range ruleIndices {
mm := models[idx]
var matched bool
switch mm.NameRule {
case model.NameRulePrefix:
matched = strings.HasPrefix(p.ModelName, mm.ModelName)
case model.NameRuleSuffix:
matched = strings.HasSuffix(p.ModelName, mm.ModelName)
case model.NameRuleContains:
matched = strings.Contains(p.ModelName, mm.ModelName)
}
if !matched {
continue
}
matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName)
es := endpointSetByIdx[idx]
if es == nil {
es = make(map[constant.EndpointType]struct{})
endpointSetByIdx[idx] = es
}
for _, et := range p.SupportedEndpointTypes {
es[et] = struct{}{}
}
gs := groupSetByIdx[idx]
if gs == nil {
gs = make(map[string]struct{})
groupSetByIdx[idx] = gs
}
for _, g := range p.EnableGroup {
gs[g] = struct{}{}
}
qs := quotaSetByIdx[idx]
if qs == nil {
qs = make(map[int]struct{})
quotaSetByIdx[idx] = qs
}
qs[p.QuotaType] = struct{}{}
}
}
// 5) 汇总所有匹配到的模型名称,批量查询一次渠道
allMatchedSet := make(map[string]struct{})
for _, names := range matchedNamesByIdx {
for _, n := range names {
allMatchedSet[n] = struct{}{}
}
}
allMatched := make([]string, 0, len(allMatchedSet))
for n := range allMatchedSet {
allMatched = append(allMatched, n)
}
matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched)
// 6) 回填每个规则模型的并集信息
for _, idx := range ruleIndices {
mm := models[idx]
// 端点并集 -> 序列化
if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" {
eps := make([]constant.EndpointType, 0, len(es))
for et := range es {
eps = append(eps, et)
}
if b, err := json.Marshal(eps); err == nil {
mm.Endpoints = string(b)
}
}
// 分组并集
if gs, ok := groupSetByIdx[idx]; ok {
groups := make([]string, 0, len(gs))
for g := range gs {
groups = append(groups, g)
}
mm.EnableGroups = groups
}
// 配额类型集合(保持去重并排序)
if qs, ok := quotaSetByIdx[idx]; ok {
arr := make([]int, 0, len(qs))
for k := range qs {
arr = append(arr, k)
}
sort.Ints(arr)
mm.QuotaTypes = arr
}
// 渠道并集
names := matchedNamesByIdx[idx]
channelSet := make(map[string]model.BoundChannel)
for _, n := range names {
for _, ch := range matchedChannelsByModel[n] {
key := ch.Name + "_" + strconv.Itoa(ch.Type)
channelSet[key] = ch
}
}
if len(channelSet) > 0 {
chs := make([]model.BoundChannel, 0, len(channelSet))
for _, ch := range channelSet {
chs = append(chs, ch)
}
mm.BoundChannels = chs
}
// 匹配信息
mm.MatchedModels = names
mm.MatchedCount = len(names)
}
}
+66 -66
View File
@@ -1,90 +1,90 @@
package controller
import (
"strconv"
"strconv"
"one-api/common"
"one-api/model"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
func GetPrefillGroups(c *gin.Context) {
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
}
// CreatePrefillGroup 创建新的预填组
func CreatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// UpdatePrefillGroup 更新预填组
func UpdatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// DeletePrefillGroup 删除预填组
func DeletePrefillGroup(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+8 -8
View File
@@ -39,14 +39,14 @@ func GetPricing(c *gin.Context) {
}
c.JSON(200, gin.H{
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": setting.AutoGroups,
})
"success": true,
"data": pricing,
"vendors": model.GetVendors(),
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": setting.AutoGroups,
})
}
func ResetModelRatio(c *gin.Context) {
+5 -5
View File
@@ -42,7 +42,11 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
case relayconstant.RelayModeResponses:
err = relay.ResponsesHelper(c)
case relayconstant.RelayModeGemini:
err = relay.GeminiHelper(c)
if strings.Contains(c.Request.URL.Path, "embed") {
err = relay.GeminiEmbeddingHandler(c)
} else {
err = relay.GeminiHelper(c)
}
default:
err = relay.TextHelper(c)
}
@@ -308,10 +312,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return true
}
if openaiErr.StatusCode == http.StatusBadRequest {
channelType := c.GetInt("channel_type")
if channelType == constant.ChannelTypeAnthropic {
return true
}
return false
}
if openaiErr.StatusCode == 408 {
+3 -3
View File
@@ -62,7 +62,7 @@ func Login(c *gin.Context) {
})
return
}
// 检查是否启用2FA
if model.IsTwoFAEnabled(user.Id) {
// 设置pending session,等待2FA验证
@@ -77,7 +77,7 @@ func Login(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "请输入两步验证码",
"success": true,
@@ -87,7 +87,7 @@ func Login(c *gin.Context) {
})
return
}
setupLogin(&user, c)
}
+93 -93
View File
@@ -1,124 +1,124 @@
package controller
import (
"strconv"
"strconv"
"one-api/common"
"one-api/model"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
// GetAllVendors 获取供应商列表(分页)
func GetAllVendors(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
var total int64
model.DB.Model(&model.Vendor{}).Count(&total)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
pageInfo := common.GetPageQuery(c)
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
var total int64
model.DB.Model(&model.Vendor{}).Count(&total)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// SearchVendors 搜索供应商
func SearchVendors(c *gin.Context) {
keyword := c.Query("keyword")
pageInfo := common.GetPageQuery(c)
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
keyword := c.Query("keyword")
pageInfo := common.GetPageQuery(c)
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(vendors)
common.ApiSuccess(c, pageInfo)
}
// GetVendorMeta 根据 ID 获取供应商
func GetVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
v, err := model.GetVendorByID(id)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, v)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
v, err := model.GetVendorByID(id)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, v)
}
// CreateVendorMeta 新建供应商
func CreateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Name == "" {
common.ApiErrorMsg(c, "供应商名称不能为空")
return
}
// 创建前先检查名称
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Name == "" {
common.ApiErrorMsg(c, "供应商名称不能为空")
return
}
// 创建前先检查名称
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
if err := v.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// UpdateVendorMeta 更新供应商
func UpdateVendorMeta(c *gin.Context) {
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Id == 0 {
common.ApiErrorMsg(c, "缺少供应商 ID")
return
}
// 名称冲突检查
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
var v model.Vendor
if err := c.ShouldBindJSON(&v); err != nil {
common.ApiError(c, err)
return
}
if v.Id == 0 {
common.ApiErrorMsg(c, "缺少供应商 ID")
return
}
// 名称冲突检查
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "供应商名称已存在")
return
}
if err := v.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
if err := v.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &v)
}
// DeleteVendorMeta 删除供应商
func DeleteVendorMeta(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
+5 -3
View File
@@ -1,6 +1,6 @@
# One API – Web 界面后端接口文档
# New API – Web 界面后端接口文档
> 本文档汇总了 **One API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
> 本文档汇总了 **New API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
>
> 接口前缀统一为 `https://<your-domain>`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。
>
@@ -62,6 +62,8 @@
| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) |
### 5.2 用户自身操作 (需登录)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/self/groups | 用户 | 获取自己所在分组 |
| GET | /api/user/self | 用户 | 获取个人资料 |
| GET | /api/user/models | 用户 | 获取模型可见性 |
@@ -192,4 +194,4 @@
---
> **更新日期**2025.07.17
> **更新日期**2025.07.17
+4
View File
@@ -8,3 +8,7 @@ type ChannelSettings struct {
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
}
+45 -4
View File
@@ -3,16 +3,52 @@ package dto
import (
"encoding/json"
"one-api/common"
"strings"
)
type GeminiChatRequest struct {
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
Tools []GeminiChatTool `json:"tools,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
}
func (r *GeminiChatRequest) GetTools() []GeminiChatTool {
var tools []GeminiChatTool
if strings.HasSuffix(string(r.Tools), "[") {
// is array
if err := common.Unmarshal(r.Tools, &tools); err != nil {
common.LogError(nil, "error_unmarshalling_tools: "+err.Error())
return nil
}
} else if strings.HasPrefix(string(r.Tools), "{") {
// is object
singleTool := GeminiChatTool{}
if err := common.Unmarshal(r.Tools, &singleTool); err != nil {
common.LogError(nil, "error_unmarshalling_single_tool: "+err.Error())
return nil
}
tools = []GeminiChatTool{singleTool}
}
return tools
}
func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
if len(tools) == 0 {
r.Tools = json.RawMessage("[]")
return
}
// Marshal the tools to JSON
data, err := common.Marshal(tools)
if err != nil {
common.LogError(nil, "error_marshalling_tools: "+err.Error())
return
}
r.Tools = data
}
type GeminiThinkingConfig struct {
IncludeThoughts bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
@@ -210,6 +246,7 @@ type GeminiImagePrediction struct {
// Embedding related structs
type GeminiEmbeddingRequest struct {
Model string `json:"model,omitempty"`
Content GeminiChatContent `json:"content"`
TaskType string `json:"taskType,omitempty"`
Title string `json:"title,omitempty"`
@@ -220,10 +257,14 @@ type GeminiBatchEmbeddingRequest struct {
Requests []*GeminiEmbeddingRequest `json:"requests"`
}
type GeminiEmbedding struct {
Values []float64 `json:"values"`
type GeminiEmbeddingResponse struct {
Embedding ContentEmbedding `json:"embedding"`
}
type GeminiBatchEmbeddingResponse struct {
Embeddings []*GeminiEmbedding `json:"embeddings"`
Embeddings []*ContentEmbedding `json:"embeddings"`
}
type ContentEmbedding struct {
Values []float64 `json:"values"`
}
+2 -1
View File
@@ -29,6 +29,7 @@ type GeneralOpenAIRequest struct {
MaxTokens uint `json:"max_tokens,omitempty"`
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Verbosity json.RawMessage `json:"verbosity,omitempty"` // gpt-5
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
@@ -53,7 +54,7 @@ type GeneralOpenAIRequest struct {
Modalities json.RawMessage `json:"modalities,omitempty"`
Audio json.RawMessage `json:"audio,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` // ali
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao,zhipu_v4
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
SearchParameters any `json:"search_parameters,omitempty"` //xai
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
+24
View File
@@ -2,6 +2,7 @@ package dto
import "one-api/constant"
// 这里不好动就不动了,本来想独立出来的(
type OpenAIModels struct {
Id string `json:"id"`
Object string `json:"object"`
@@ -9,3 +10,26 @@ type OpenAIModels struct {
OwnedBy string `json:"owned_by"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
}
type AnthropicModel struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
}
type GeminiModel struct {
Name interface{} `json:"name"`
BaseModelId interface{} `json:"baseModelId"`
Version interface{} `json:"version"`
DisplayName interface{} `json:"displayName"`
Description interface{} `json:"description"`
InputTokenLimit interface{} `json:"inputTokenLimit"`
OutputTokenLimit interface{} `json:"outputTokenLimit"`
SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"`
Thinking interface{} `json:"thinking"`
Temperature interface{} `json:"temperature"`
MaxTemperature interface{} `json:"maxTemperature"`
TopP interface{} `json:"topP"`
TopK interface{} `json:"topK"`
}
+9 -7
View File
@@ -192,16 +192,18 @@ func TokenAuth() func(c *gin.Context) {
}
c.Request.Header.Set("Authorization", "Bearer "+key)
}
anthropicKey := c.Request.Header.Get("x-api-key")
// 检查path包含/v1/messages
if strings.Contains(c.Request.URL.Path, "/v1/messages") {
// 从x-api-key中获取key
key := c.Request.Header.Get("x-api-key")
if key != "" {
c.Request.Header.Set("Authorization", "Bearer "+key)
}
// 或者是否 x-api-key 不为空且存在anthropic-version
// 谁知道有多少不符合规范没写anthropic-version的
// 所以就这样随它去吧(
if strings.Contains(c.Request.URL.Path, "/v1/messages") || (anthropicKey != "" && c.Request.Header.Get("anthropic-version") != "") {
c.Request.Header.Set("Authorization", "Bearer "+anthropicKey)
}
// gemini api 从query中获取key
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") ||
strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/models") ||
strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
skKey := c.Query("key")
if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+skKey)
+4 -1
View File
@@ -174,7 +174,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
}
c.Set("relay_mode", relayMode)
if _, ok := c.Get("relay_mode"); !ok {
c.Set("relay_mode", relayMode)
}
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
relayMode := relayconstant.RelayModeGemini
@@ -244,6 +246,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
@@ -0,0 +1,80 @@
package middleware
import (
"context"
"fmt"
"net/http"
"one-api/common"
"time"
"github.com/gin-gonic/gin"
)
const (
EmailVerificationRateLimitMark = "EV"
EmailVerificationMaxRequests = 2 // 30秒内最多2次
EmailVerificationDuration = 30 // 30秒时间窗口
)
func redisEmailVerificationRateLimiter(c *gin.Context) {
ctx := context.Background()
rdb := common.RDB
key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP()
count, err := rdb.Incr(ctx, key).Result()
if err != nil {
// fallback
memoryEmailVerificationRateLimiter(c)
return
}
// 第一次设置键时设置过期时间
if count == 1 {
_ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err()
}
// 检查是否超出限制
if count <= int64(EmailVerificationMaxRequests) {
c.Next()
return
}
// 获取剩余等待时间
ttl, err := rdb.TTL(ctx, key).Result()
waitSeconds := int64(EmailVerificationDuration)
if err == nil && ttl > 0 {
waitSeconds = int64(ttl.Seconds())
}
c.JSON(http.StatusTooManyRequests, gin.H{
"success": false,
"message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds),
})
c.Abort()
}
func memoryEmailVerificationRateLimiter(c *gin.Context) {
key := EmailVerificationRateLimitMark + ":" + c.ClientIP()
if !inMemoryRateLimiter.Request(key, EmailVerificationMaxRequests, EmailVerificationDuration) {
c.JSON(http.StatusTooManyRequests, gin.H{
"success": false,
"message": "发送过于频繁,请稍后再试",
})
c.Abort()
return
}
c.Next()
}
func EmailVerificationRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
if common.RedisEnabled {
redisEmailVerificationRateLimiter(c)
} else {
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
memoryEmailVerificationRateLimiter(c)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package middleware
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
"one-api/constant"
relayconstant "one-api/relay/constant"
)
func JimengRequestConvert() func(c *gin.Context) {
return func(c *gin.Context) {
action := c.Query("Action")
if action == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required")
return
}
// Handle Jimeng official API request
var originalReq map[string]interface{}
if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body")
return
}
model, _ := originalReq["req_key"].(string)
prompt, _ := originalReq["prompt"].(string)
unifiedReq := map[string]interface{}{
"model": model,
"prompt": prompt,
"metadata": originalReq,
}
jsonData, err := json.Marshal(unifiedReq)
if err != nil {
abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body")
return
}
// Update request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
c.Set(common.KeyRequestBody, jsonData)
if image, ok := originalReq["image"]; !ok || image == "" {
c.Set("action", constant.TaskActionTextGenerate)
}
c.Request.URL.Path = "/v1/video/generations"
if action == "CVSync2AsyncGetResult" {
taskId, ok := originalReq["task_id"].(string)
if !ok || taskId == "" {
abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult")
return
}
c.Request.URL.Path = "/v1/video/generations/" + taskId
c.Request.Method = http.MethodGet
c.Set("task_id", taskId)
c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
}
c.Next()
}
}
+23 -1
View File
@@ -42,7 +42,7 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Settings string `json:"settings"`
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
@@ -838,6 +838,28 @@ func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
channel.Setting = common.GetPointer[string](string(settingBytes))
}
func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings {
setting := dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
err := common.UnmarshalJsonStr(channel.OtherSettings, &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
channel.OtherSettings = "{}" // 清空设置以避免后续错误
_ = channel.Save() // 保存修改
}
}
return setting
}
func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) {
settingBytes, err := common.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
return
}
channel.OtherSettings = string(settingBytes)
}
func (channel *Channel) GetParamOverride() map[string]interface{} {
paramOverride := make(map[string]interface{})
if channel.ParamOverride != nil && *channel.ParamOverride != "" {
+139 -4
View File
@@ -64,6 +64,22 @@ var DB *gorm.DB
var LOG_DB *gorm.DB
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
func dropIndexIfExists(tableName string, indexName string) {
if !common.UsingMySQL {
return
}
var count int64
// Check index existence via information_schema
err := DB.Raw(
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
tableName, indexName,
).Scan(&count).Error
if err == nil && count > 0 {
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
}
}
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
@@ -180,6 +196,12 @@ func InitDB() (err error) {
db = db.Debug()
}
DB = db
// MySQL charset/collation startup check: ensure Chinese-capable charset
if common.UsingMySQL {
if err := checkMySQLChineseSupport(DB); err != nil {
panic(err)
}
}
sqlDB, err := DB.DB()
if err != nil {
return err
@@ -214,6 +236,12 @@ func InitLogDB() (err error) {
db = db.Debug()
}
LOG_DB = db
// If log DB is MySQL, also ensure Chinese-capable charset
if common.LogSqlType == common.DatabaseTypeMySQL {
if err := checkMySQLChineseSupport(LOG_DB); err != nil {
panic(err)
}
}
sqlDB, err := LOG_DB.DB()
if err != nil {
return err
@@ -235,9 +263,16 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
if !common.UsingPostgreSQL {
return migrateDBFast()
}
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引 (model_name, deleted_at) 冲突
dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称
dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在)
dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称
//if !common.UsingPostgreSQL {
// return migrateDBFast()
//}
err := DB.AutoMigrate(
&Channel{},
&Token{},
@@ -264,6 +299,14 @@ func migrateDB() error {
}
func migrateDBFast() error {
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
// 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引冲突
dropIndexIfExists("models", "uk_model_name")
dropIndexIfExists("models", "model_name")
dropIndexIfExists("vendors", "uk_vendor_name")
dropIndexIfExists("vendors", "name")
var wg sync.WaitGroup
migrations := []struct {
@@ -282,7 +325,7 @@ func migrateDBFast() error {
{&QuotaData{}, "QuotaData"},
{&Task{}, "Task"},
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"},
@@ -342,6 +385,98 @@ func CloseDB() error {
return closeDB(DB)
}
// checkMySQLChineseSupport ensures the MySQL connection and current schema
// default charset/collation can store Chinese characters. It allows common
// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise.
func checkMySQLChineseSupport(db *gorm.DB) error {
// 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集)
// Read current schema defaults
var schemaCharset, schemaCollation string
err := db.Raw("SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()").Row().Scan(&schemaCharset, &schemaCollation)
if err != nil {
return fmt.Errorf("读取当前库默认字符集/排序规则失败 / Failed to read schema default charset/collation: %v", err)
}
toLower := func(s string) string { return strings.ToLower(s) }
// Allowed charsets that can store Chinese text
allowedCharsets := map[string]string{
"utf8mb4": "utf8mb4_",
"utf8": "utf8_",
"gbk": "gbk_",
"big5": "big5_",
"gb18030": "gb18030_",
}
isChineseCapable := func(cs, cl string) bool {
csLower := toLower(cs)
clLower := toLower(cl)
if prefix, ok := allowedCharsets[csLower]; ok {
if clLower == "" {
return true
}
return strings.HasPrefix(clLower, prefix)
}
// 如果仅提供了排序规则,尝试按排序规则前缀判断
for _, prefix := range allowedCharsets {
if strings.HasPrefix(clLower, prefix) {
return true
}
}
return false
}
// 1) 当前库默认值必须支持中文
if !isChineseCapable(schemaCharset, schemaCollation) {
return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030",
schemaCharset, schemaCollation, schemaCharset, schemaCollation)
}
// 2) 所有物理表的排序规则(隐含字符集)必须支持中文
type tableInfo struct {
Name string
Collation *string
}
var tables []tableInfo
if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil {
return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err)
}
var badTables []string
for _, t := range tables {
// NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过
if t.Collation == nil || *t.Collation == "" {
continue
}
cl := *t.Collation
// 仅凭排序规则判断是否中文可用
ok := false
lower := strings.ToLower(cl)
for _, prefix := range allowedCharsets {
if strings.HasPrefix(lower, prefix) {
ok = true
break
}
}
if !ok {
badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl))
}
}
if len(badTables) > 0 {
// 限制输出数量以避免日志过长
maxShow := 20
shown := badTables
if len(shown) > maxShow {
shown = shown[:maxShow]
}
return fmt.Errorf(
"存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v",
maxShow, shown, maxShow, shown,
)
}
return nil
}
var (
lastPingTime time.Time
pingMutex sync.Mutex
+22 -22
View File
@@ -2,29 +2,29 @@ package model
// GetMissingModels returns model names that are referenced in the system
func GetMissingModels() ([]string, error) {
// 1. 获取所有已启用模型(去重)
models := GetEnabledModels()
if len(models) == 0 {
return []string{}, nil
}
// 1. 获取所有已启用模型(去重)
models := GetEnabledModels()
if len(models) == 0 {
return []string{}, nil
}
// 2. 查询已有的元数据模型名
var existing []string
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
return nil, err
}
// 2. 查询已有的元数据模型名
var existing []string
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
return nil, err
}
existingSet := make(map[string]struct{}, len(existing))
for _, e := range existing {
existingSet[e] = struct{}{}
}
existingSet := make(map[string]struct{}, len(existing))
for _, e := range existing {
existingSet[e] = struct{}{}
}
// 3. 收集缺失模型
var missing []string
for _, name := range models {
if _, ok := existingSet[name]; !ok {
missing = append(missing, name)
}
}
return missing, nil
// 3. 收集缺失模型
var missing []string
for _, name := range models {
if _, ok := existingSet[name]; !ok {
missing = append(missing, name)
}
}
return missing, nil
}
+22 -25
View File
@@ -1,34 +1,31 @@
package model
// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。
func GetModelEnableGroups(modelName string) []string {
// 确保缓存最新
GetPricing()
// 确保缓存最新
GetPricing()
if modelName == "" {
return make([]string, 0)
}
if modelName == "" {
return make([]string, 0)
}
modelEnableGroupsLock.RLock()
groups, ok := modelEnableGroups[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return make([]string, 0)
}
return groups
modelEnableGroupsLock.RLock()
groups, ok := modelEnableGroups[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return make([]string, 0)
}
return groups
}
// GetModelQuotaType 返回指定模型的计费类型quota_type)。
// 同样使用缓存映射,避免每次遍历定价切片。
func GetModelQuotaType(modelName string) int {
GetPricing()
// GetModelQuotaTypes 返回指定模型的计费类型集合(来自缓存)
func GetModelQuotaTypes(modelName string) []int {
GetPricing()
modelEnableGroupsLock.RLock()
quota, ok := modelQuotaTypeMap[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return 0
}
return quota
modelEnableGroupsLock.RLock()
quota, ok := modelQuotaTypeMap[modelName]
modelEnableGroupsLock.RUnlock()
if !ok {
return []int{}
}
return []int{quota}
}
+108 -166
View File
@@ -1,204 +1,146 @@
package model
import (
"one-api/common"
"strconv"
"strings"
"one-api/common"
"strconv"
"gorm.io/gorm"
"gorm.io/gorm"
)
// Model 用于存储模型的元数据,例如描述、标签等
// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展
// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
//
// 该表设计遵循第三范式(3NF):
// 1. 每一列都与主键(Id 或 ModelName)直接相关
// 2. 不存在部分依赖(ModelName 是唯一键)
// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列)
// 这样既保证了数据一致性,也方便后期扩展
// 模型名称匹配规则
const (
NameRuleExact = iota // 0 精确匹配
NameRulePrefix // 1 前缀匹配
NameRuleContains // 2 包含匹配
NameRuleSuffix // 3 后缀匹配
NameRuleExact = iota
NameRulePrefix
NameRuleContains
NameRuleSuffix
)
type BoundChannel struct {
Name string `json:"name"`
Type int `json:"type"`
Name string `json:"name"`
Type int `json:"type"`
}
type Model struct {
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
Description string `json:"description,omitempty" gorm:"type:text"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Id int `json:"id"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
QuotaType int `json:"quota_type" gorm:"-"`
NameRule int `json:"name_rule" gorm:"default:0"`
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
QuotaTypes []int `json:"quota_types,omitempty" gorm:"-"`
NameRule int `json:"name_rule" gorm:"default:0"`
MatchedModels []string `json:"matched_models,omitempty" gorm:"-"`
MatchedCount int `json:"matched_count,omitempty" gorm:"-"`
}
// Insert 创建新的模型元数据记录
func (mi *Model) Insert() error {
now := common.GetTimestamp()
mi.CreatedTime = now
mi.UpdatedTime = now
return DB.Create(mi).Error
now := common.GetTimestamp()
mi.CreatedTime = now
mi.UpdatedTime = now
return DB.Create(mi).Error
}
// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID)
func IsModelNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新现有模型记录
func (mi *Model) Update() error {
// 仅更新需要变更的字段,避免覆盖 CreatedTime
mi.UpdatedTime = common.GetTimestamp()
// 排除 created_time,其余字段自动更新,避免新增字段时需要维护列表
return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error
mi.UpdatedTime = common.GetTimestamp()
return DB.Session(&gorm.Session{AllowGlobalUpdate: false, FullSaveAssociations: false}).
Model(&Model{}).
Where("id = ?", mi.Id).
Omit("created_time").
Select("*").
Updates(mi).Error
}
// Delete 软删除模型记录
func (mi *Model) Delete() error {
return DB.Delete(mi).Error
return DB.Delete(mi).Error
}
// GetModelByName 根据模型名称查询元数据
func GetModelByName(name string) (*Model, error) {
var mi Model
err := DB.Where("model_name = ?", name).First(&mi).Error
if err != nil {
return nil, err
}
return &mi, nil
}
// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响)
func GetVendorModelCounts() (map[int64]int64, error) {
var stats []struct {
VendorID int64
Count int64
}
if err := DB.Model(&Model{}).
Select("vendor_id as vendor_id, count(*) as count").
Group("vendor_id").
Scan(&stats).Error; err != nil {
return nil, err
}
m := make(map[int64]int64, len(stats))
for _, s := range stats {
m[s.VendorID] = s.Count
}
return m, nil
var stats []struct {
VendorID int64
Count int64
}
if err := DB.Model(&Model{}).
Select("vendor_id as vendor_id, count(*) as count").
Group("vendor_id").
Scan(&stats).Error; err != nil {
return nil, err
}
m := make(map[int64]int64, len(stats))
for _, s := range stats {
m[s.VendorID] = s.Count
}
return m, nil
}
// GetAllModels 分页获取所有模型元数据
func GetAllModels(offset int, limit int) ([]*Model, error) {
var models []*Model
err := DB.Offset(offset).Limit(limit).Find(&models).Error
return models, err
var models []*Model
err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&models).Error
return models, err
}
// GetBoundChannels 查询支持该模型的渠道(名称+类型)
func GetBoundChannels(modelName string) ([]BoundChannel, error) {
var channels []BoundChannel
err := DB.Table("channels").
Select("channels.name, channels.type").
Joins("join abilities on abilities.channel_id = channels.id").
Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
Group("channels.id").
Scan(&channels).Error
return channels, err
func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel, error) {
result := make(map[string][]BoundChannel)
if len(modelNames) == 0 {
return result, nil
}
type row struct {
Model string
Name string
Type int
}
var rows []row
err := DB.Table("channels").
Select("abilities.model as model, channels.name as name, channels.type as type").
Joins("JOIN abilities ON abilities.channel_id = channels.id").
Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true).
Distinct().
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, r := range rows {
result[r.Model] = append(result[r.Model], BoundChannel{Name: r.Name, Type: r.Type})
}
return result, nil
}
// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含
func FindModelByNameWithRule(name string) (*Model, error) {
// 1. 精确匹配
if m, err := GetModelByName(name); err == nil {
return m, nil
}
// 2. 规则匹配
var models []*Model
if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
return nil, err
}
var prefixMatch, suffixMatch, containsMatch *Model
for _, m := range models {
switch m.NameRule {
case NameRulePrefix:
if strings.HasPrefix(name, m.ModelName) {
if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
prefixMatch = m
}
}
case NameRuleSuffix:
if strings.HasSuffix(name, m.ModelName) {
if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
suffixMatch = m
}
}
case NameRuleContains:
if strings.Contains(name, m.ModelName) {
if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
containsMatch = m
}
}
}
}
if prefixMatch != nil {
return prefixMatch, nil
}
if suffixMatch != nil {
return suffixMatch, nil
}
if containsMatch != nil {
return containsMatch, nil
}
return nil, gorm.ErrRecordNotFound
}
// SearchModels 根据关键词和供应商搜索模型,支持分页
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
var models []*Model
db := DB.Model(&Model{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
}
if vendor != "" {
// 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
if vid, err := strconv.Atoi(vendor); err == nil {
db = db.Where("models.vendor_id = ?", vid)
} else {
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
}
}
var total int64
err := db.Count(&total).Error
if err != nil {
return nil, 0, err
}
err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
return models, total, err
var models []*Model
db := DB.Model(&Model{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
}
if vendor != "" {
if vid, err := strconv.Atoi(vendor); err == nil {
db = db.Where("models.vendor_id = ?", vid)
} else {
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
}
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
return nil, 0, err
}
return models, total, nil
}
+2
View File
@@ -336,6 +336,8 @@ func updateOptionMap(key string, value string) (err error) {
common.LinuxDOClientId = value
case "LinuxDOClientSecret":
common.LinuxDOClientSecret = value
case "LinuxDOMinimumTrustLevel":
common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value)
case "Footer":
common.Footer = value
case "SystemName":
+72 -72
View File
@@ -1,11 +1,11 @@
package model
import (
"encoding/json"
"database/sql/driver"
"one-api/common"
"database/sql/driver"
"encoding/json"
"one-api/common"
"gorm.io/gorm"
"gorm.io/gorm"
)
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
@@ -20,107 +20,107 @@ type JSONValue json.RawMessage
// Value 实现 driver.Valuer 接口,用于数据库写入
func (j JSONValue) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
return []byte(j), nil
if j == nil {
return nil, nil
}
return []byte(j), nil
}
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
func (j *JSONValue) Scan(value interface{}) error {
switch v := value.(type) {
case nil:
*j = nil
return nil
case []byte:
// 拷贝底层字节,避免保留底层缓冲区
b := make([]byte, len(v))
copy(b, v)
*j = JSONValue(b)
return nil
case string:
*j = JSONValue([]byte(v))
return nil
default:
// 其他类型尝试序列化为 JSON
b, err := json.Marshal(v)
if err != nil {
return err
}
*j = JSONValue(b)
return nil
}
switch v := value.(type) {
case nil:
*j = nil
return nil
case []byte:
// 拷贝底层字节,避免保留底层缓冲区
b := make([]byte, len(v))
copy(b, v)
*j = JSONValue(b)
return nil
case string:
*j = JSONValue([]byte(v))
return nil
default:
// 其他类型尝试序列化为 JSON
b, err := json.Marshal(v)
if err != nil {
return err
}
*j = JSONValue(b)
return nil
}
}
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
func (j JSONValue) MarshalJSON() ([]byte, error) {
if j == nil {
return []byte("null"), nil
}
return j, nil
if j == nil {
return []byte("null"), nil
}
return j, nil
}
// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
func (j *JSONValue) UnmarshalJSON(data []byte) error {
if data == nil {
*j = nil
return nil
}
b := make([]byte, len(data))
copy(b, data)
*j = JSONValue(b)
return nil
if data == nil {
*j = nil
return nil
}
b := make([]byte, len(data))
copy(b, data)
*j = JSONValue(b)
return nil
}
type PrefillGroup struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
Type string `json:"type" gorm:"size:32;index;not null"`
Items JSONValue `json:"items" gorm:"type:json"`
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Id int `json:"id"`
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
Type string `json:"type" gorm:"size:32;index;not null"`
Items JSONValue `json:"items" gorm:"type:json"`
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// Insert 新建组
func (g *PrefillGroup) Insert() error {
now := common.GetTimestamp()
g.CreatedTime = now
g.UpdatedTime = now
return DB.Create(g).Error
now := common.GetTimestamp()
g.CreatedTime = now
g.UpdatedTime = now
return DB.Create(g).Error
}
// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID)
func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新组
func (g *PrefillGroup) Update() error {
g.UpdatedTime = common.GetTimestamp()
return DB.Save(g).Error
g.UpdatedTime = common.GetTimestamp()
return DB.Save(g).Error
}
// DeleteByID 根据 ID 删除组
func DeletePrefillGroupByID(id int) error {
return DB.Delete(&PrefillGroup{}, id).Error
return DB.Delete(&PrefillGroup{}, id).Error
}
// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
var groups []*PrefillGroup
query := DB.Model(&PrefillGroup{})
if groupType != "" {
query = query.Where("type = ?", groupType)
}
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
var groups []*PrefillGroup
query := DB.Model(&PrefillGroup{})
if groupType != "" {
query = query.Where("type = ?", groupType)
}
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
}
+141 -139
View File
@@ -1,30 +1,31 @@
package model
import (
"encoding/json"
"fmt"
"strings"
"encoding/json"
"fmt"
"strings"
"one-api/common"
"one-api/constant"
"one-api/setting/ratio_setting"
"one-api/types"
"sync"
"time"
"one-api/common"
"one-api/constant"
"one-api/setting/ratio_setting"
"one-api/types"
"sync"
"time"
)
type Pricing struct {
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
}
type PricingVendor struct {
@@ -35,11 +36,11 @@ type PricingVendor struct {
}
var (
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex
// 缓存映射:模型名 -> 启用分组 / 计费类型
modelEnableGroups = make(map[string][]string)
@@ -121,19 +122,19 @@ func updatePricing() {
for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range suffixList {
for _, pricingModel := range enableAbilities {
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range containsList {
@@ -179,34 +180,34 @@ func updatePricing() {
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}
// 再补充模型自定义端点
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := modelSupportEndpointsStr[modelName]
for k := range raw {
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
modelSupportEndpointsStr[modelName] = endpoints
}
}
// 再补充模型自定义端点
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := modelSupportEndpointsStr[modelName]
for k := range raw {
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
modelSupportEndpointsStr[modelName] = endpoints
}
}
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr {
@@ -216,92 +217,93 @@ func updatePricing() {
supportedEndpoints = append(supportedEndpoints, endpointType)
}
modelSupportEndpointTypes[model] = supportedEndpoints
}
}
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
if _, exists := supportedEndpointMap[string(et)]; !exists {
supportedEndpointMap[string(et)] = info
}
}
}
}
// 2. 自定义端点(models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
case map[string]interface{}:
ep := common.EndpointInfo{Method: "POST"}
if p, ok := val["path"].(string); ok {
ep.Path = p
}
if m, ok := val["method"].(string); ok {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
if _, exists := supportedEndpointMap[string(et)]; !exists {
supportedEndpointMap[string(et)] = info
}
}
}
}
// 2. 自定义端点(models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
case map[string]interface{}:
ep := common.EndpointInfo{Method: "POST"}
if p, ok := val["path"].(string); ok {
ep.Path = p
}
if m, ok := val["method"].(string); ok {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}
pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}
// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
if meta.Status != 1 {
continue
}
pricing.Description = meta.Description
pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricingMap = append(pricingMap, pricing)
}
// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
if meta.Status != 1 {
continue
}
pricing.Description = meta.Description
pricing.Icon = meta.Icon
pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricingMap = append(pricingMap, pricing)
}
// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
}
modelEnableGroupsLock.Unlock()
// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
}
modelEnableGroupsLock.Unlock()
lastGetPricingTime = time.Now()
lastGetPricingTime = time.Now()
}
// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
return supportedEndpointMap
}
+5 -5
View File
@@ -4,11 +4,11 @@ package model
// 该方法用于需要最新数据的内部管理 API,
// 因此会绕过默认的 1 分钟延迟刷新。
func RefreshPricing() {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
updatePricing()
}
+47 -47
View File
@@ -1,9 +1,9 @@
package model
import (
"one-api/common"
"one-api/common"
"gorm.io/gorm"
"gorm.io/gorm"
)
// Vendor 用于存储供应商信息,供模型引用
@@ -13,76 +13,76 @@ import (
// 本表同样遵循 3NF 设计范式
type Vendor struct {
Id int `json:"id"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
Id int `json:"id"`
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
}
// Insert 创建新的供应商记录
func (v *Vendor) Insert() error {
now := common.GetTimestamp()
v.CreatedTime = now
v.UpdatedTime = now
return DB.Create(v).Error
now := common.GetTimestamp()
v.CreatedTime = now
v.UpdatedTime = now
return DB.Create(v).Error
}
// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID)
func IsVendorNameDuplicated(id int, name string) (bool, error) {
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
if name == "" {
return false, nil
}
var cnt int64
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
return cnt > 0, err
}
// Update 更新供应商记录
func (v *Vendor) Update() error {
v.UpdatedTime = common.GetTimestamp()
return DB.Save(v).Error
v.UpdatedTime = common.GetTimestamp()
return DB.Save(v).Error
}
// Delete 软删除供应商
func (v *Vendor) Delete() error {
return DB.Delete(v).Error
return DB.Delete(v).Error
}
// GetVendorByID 根据 ID 获取供应商
func GetVendorByID(id int) (*Vendor, error) {
var v Vendor
err := DB.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
var v Vendor
err := DB.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
}
// GetAllVendors 获取全部供应商(分页)
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
var vendors []*Vendor
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
return vendors, err
var vendors []*Vendor
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
return vendors, err
}
// SearchVendors 按关键字搜索供应商
func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) {
db := DB.Model(&Vendor{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var vendors []*Vendor
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
return nil, 0, err
}
return vendors, total, nil
}
db := DB.Model(&Vendor{})
if keyword != "" {
like := "%" + keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var vendors []*Vendor
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
return nil, 0, err
}
return vendors, total, nil
}
+4 -5
View File
@@ -1,7 +1,6 @@
package aws
import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
@@ -113,14 +112,14 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
}
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = json.Marshal(awsClaudeReq)
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
if err != nil {
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
}
claudeInfo := &claude.ClaudeResponseInfo{
@@ -165,14 +164,14 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = json.Marshal(awsClaudeReq)
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)
if err != nil {
return types.NewError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeChannelAwsClientError), nil
return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
}
stream := awsResp.GetStream()
defer stream.Close()
+10 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
@@ -38,6 +39,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/chat/completions", info.BaseUrl, info.ApiVersion), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/embeddings", info.BaseUrl, info.ApiVersion), nil
case constant.RelayModeResponses:
return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/responses", info.BaseUrl, info.ApiVersion), nil
default:
return fmt.Sprintf("%s/client/v4/accounts/%s/ai/run/%s", info.BaseUrl, info.ApiVersion, info.UpstreamModelName), nil
}
@@ -62,8 +65,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
@@ -110,6 +112,12 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
} else {
err, usage = cfHandler(c, info, resp)
}
case constant.RelayModeResponses:
if info.IsStream {
usage, err = openai.OaiResponsesStreamHandler(c, info, resp)
} else {
usage, err = openai.OaiResponsesHandler(c, info, resp)
}
case constant.RelayModeAudioTranslation:
fallthrough
case constant.RelayModeAudioTranscription:
+13 -14
View File
@@ -114,7 +114,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if strings.HasPrefix(info.UpstreamModelName, "text-embedding") ||
strings.HasPrefix(info.UpstreamModelName, "embedding") ||
strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") {
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil
action := "embedContent"
if info.IsGeminiBatchEmbedding {
action = "batchEmbedContents"
}
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
}
action := "generateContent"
@@ -159,6 +163,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
if len(inputs) == 0 {
return nil, errors.New("input is empty")
}
// We always build a batch-style payload with `requests`, so ensure we call the
// batch endpoint upstream to avoid payload/endpoint mismatches.
info.IsGeminiBatchEmbedding = true
// process all inputs
geminiRequests := make([]map[string]interface{}, 0, len(inputs))
for _, input := range inputs {
@@ -176,7 +183,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
// set specific parameters for different models
// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
switch info.UpstreamModelName {
case "text-embedding-004","gemini-embedding-exp-03-07","gemini-embedding-001":
case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001":
// Only newer models introduced after 2024 support OutputDimensionality
if request.Dimensions > 0 {
geminiRequest["outputDimensionality"] = request.Dimensions
@@ -201,6 +208,10 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeGemini {
if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
return NativeGeminiEmbeddingHandler(c, resp, info)
}
if info.IsStream {
return GeminiTextGenerationStreamHandler(c, info, resp)
} else {
@@ -225,18 +236,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return GeminiChatHandler(c, info, resp)
}
//if usage.(*dto.Usage).CompletionTokenDetails.ReasoningTokens > 100 {
// // 没有请求-thinking的情况下,产生思考token,则按照思考模型计费
// if !strings.HasSuffix(info.OriginModelName, "-thinking") &&
// !strings.HasSuffix(info.OriginModelName, "-nothinking") {
// thinkingModelName := info.OriginModelName + "-thinking"
// if operation_setting.SelfUseModeEnabled || helper.ContainPriceOrRatio(thinkingModelName) {
// info.OriginModelName = thinkingModelName
// }
// }
//}
return nil, types.NewError(errors.New("not implemented"), types.ErrorCodeBadResponseBody)
}
func (a *Adaptor) GetModelList() []string {
+36 -5
View File
@@ -1,7 +1,6 @@
package gemini
import (
"github.com/pkg/errors"
"io"
"net/http"
"one-api/common"
@@ -12,6 +11,8 @@ import (
"one-api/types"
"strings"
"github.com/pkg/errors"
"github.com/gin-gonic/gin"
)
@@ -52,15 +53,45 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
}
}
// 直接返回 Gemini 原生格式的 JSON 响应
jsonResponse, err := common.Marshal(geminiResponse)
common.IOCopyBytesGracefully(c, resp, responseBody)
return &usage, nil
}
func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
defer common.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
common.IOCopyBytesGracefully(c, resp, jsonResponse)
if common.DebugEnabled {
println(string(responseBody))
}
return &usage, nil
usage := &dto.Usage{
PromptTokens: info.PromptTokens,
TotalTokens: info.PromptTokens,
}
if info.IsGeminiBatchEmbedding {
var geminiResponse dto.GeminiBatchEmbeddingResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
} else {
var geminiResponse dto.GeminiEmbeddingResponse
err = common.Unmarshal(responseBody, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
}
common.IOCopyBytesGracefully(c, resp, responseBody)
return usage, nil
}
func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+5 -6
View File
@@ -267,24 +267,23 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
tool.Function.Parameters = cleanedParams
functions = append(functions, tool.Function)
}
geminiTools := geminiRequest.GetTools()
if codeExecution {
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
geminiTools = append(geminiTools, dto.GeminiChatTool{
CodeExecution: make(map[string]string),
})
}
if googleSearch {
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
geminiTools = append(geminiTools, dto.GeminiChatTool{
GoogleSearch: make(map[string]string),
})
}
if len(functions) > 0 {
geminiRequest.Tools = append(geminiRequest.Tools, dto.GeminiChatTool{
geminiTools = append(geminiTools, dto.GeminiChatTool{
FunctionDeclarations: functions,
})
}
// common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools))
// json_data, _ := json.Marshal(geminiRequest.Tools)
// common.SysLog("tools_json: " + string(json_data))
geminiRequest.SetTools(geminiTools)
}
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
+25 -8
View File
@@ -126,9 +126,26 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
task := strings.TrimPrefix(requestURL, "/v1/")
if info.RelayFormat == relaycommon.RelayFormatClaude {
task = strings.TrimPrefix(task, "messages")
task = "chat/completions" + task
}
// 特殊处理 responses API
if info.RelayMode == relayconstant.RelayModeResponses {
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview")
responsesApiVersion := "preview"
subUrl := "/openai/v1/responses"
if strings.Contains(info.BaseUrl, "cognitiveservices.azure.com") {
subUrl = "/openai/responses"
responsesApiVersion = apiVersion
}
if info.ChannelOtherSettings.AzureResponsesVersion != "" {
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
}
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil
}
@@ -239,34 +256,34 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
}
if strings.HasPrefix(request.Model, "o") || strings.HasPrefix(request.Model, "gpt-5") {
if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = 0
}
if strings.HasPrefix(request.Model, "o") {
if strings.HasPrefix(info.UpstreamModelName, "o") {
request.Temperature = nil
}
if strings.HasPrefix(request.Model, "gpt-5") {
if request.Model != "gpt-5-chat-latest" {
if strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
if info.UpstreamModelName != "gpt-5-chat-latest" {
request.Temperature = nil
}
}
// 转换模型推理力度后缀
effort, originModel := parseReasoningEffortFromModelSuffix(request.Model)
effort, originModel := parseReasoningEffortFromModelSuffix(info.UpstreamModelName)
if effort != "" {
request.ReasoningEffort = effort
info.UpstreamModelName = originModel
request.Model = originModel
}
info.ReasoningEffort = request.ReasoningEffort
info.UpstreamModelName = request.Model
// o系列模型developer适配(o1-mini除外)
if !strings.HasPrefix(request.Model, "o1-mini") && !strings.HasPrefix(request.Model, "o1-preview") {
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
//修改第一个Message的内容,将system改为developer
if len(request.Messages) > 0 && request.Messages[0].Role == "system" {
request.Messages[0].Role = "developer"
+1
View File
@@ -50,5 +50,6 @@ func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReq
Stop: Stop,
Tools: request.Tools,
ToolChoice: request.ToolChoice,
THINKING: request.THINKING,
}
}
+15 -7
View File
@@ -74,13 +74,14 @@ type RelayInfo struct {
FirstResponseTime time.Time
isFirstResponse bool
//SendLastReasoningResponse bool
ApiType int
IsStream bool
IsPlayground bool
UsePrice bool
RelayMode int
UpstreamModelName string
OriginModelName string
ApiType int
IsStream bool
IsGeminiBatchEmbedding bool
IsPlayground bool
UsePrice bool
RelayMode int
UpstreamModelName string
OriginModelName string
//RecodeModelName string
RequestURLPath string
ApiVersion string
@@ -101,6 +102,7 @@ type RelayInfo struct {
AudioUsage bool
ReasoningEffort string
ChannelSetting dto.ChannelSettings
ChannelOtherSettings dto.ChannelOtherSettings
ParamOverride map[string]interface{}
UserSetting dto.UserSetting
UserEmail string
@@ -291,6 +293,12 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
if ok {
info.ChannelSetting = channelSetting
}
channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting)
if ok {
info.ChannelOtherSettings = channelOtherSettings
}
userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting)
if ok {
info.UserSetting = userSetting
+115
View File
@@ -264,3 +264,118 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
return nil
}
func GeminiEmbeddingHandler(c *gin.Context) (newAPIError *types.NewAPIError) {
relayInfo := relaycommon.GenRelayInfoGemini(c)
isBatch := strings.HasSuffix(c.Request.URL.Path, "batchEmbedContents")
relayInfo.IsGeminiBatchEmbedding = isBatch
var promptTokens int
var req any
var err error
var inputTexts []string
if isBatch {
batchRequest := &dto.GeminiBatchEmbeddingRequest{}
err = common.UnmarshalBodyReusable(c, batchRequest)
if err != nil {
return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
req = batchRequest
for _, r := range batchRequest.Requests {
for _, part := range r.Content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
}
} else {
singleRequest := &dto.GeminiEmbeddingRequest{}
err = common.UnmarshalBodyReusable(c, singleRequest)
if err != nil {
return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}
req = singleRequest
for _, part := range singleRequest.Content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
}
promptTokens = service.CountTokenInput(strings.Join(inputTexts, "\n"), relayInfo.UpstreamModelName)
relayInfo.SetPromptTokens(promptTokens)
c.Set("prompt_tokens", promptTokens)
err = helper.ModelMappedHelper(c, relayInfo, req)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, 0)
if err != nil {
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
}
preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if newAPIError != nil {
return newAPIError
}
defer func() {
if newAPIError != nil {
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
}
}()
adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil {
return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
}
adaptor.Init(relayInfo)
var requestBody io.Reader
jsonData, err := common.Marshal(req)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
// apply param override
if len(relayInfo.ParamOverride) > 0 {
reqMap := make(map[string]interface{})
_ = common.Unmarshal(jsonData, &reqMap)
for key, value := range relayInfo.ParamOverride {
reqMap[key] = value
}
jsonData, err = common.Marshal(reqMap)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
}
requestBody = bytes.NewReader(jsonData)
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
if err != nil {
common.LogError(c, "Do gemini request failed: "+err.Error())
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(httpResp, false)
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
}
usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo)
if openaiErr != nil {
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
return openaiErr
}
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
return nil
}
+4 -6
View File
@@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
}
}()
includeUsage := false
includeUsage := true
// 判断用户是否需要返回使用情况
if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage {
includeUsage = true
if textRequest.StreamOptions != nil {
includeUsage = textRequest.StreamOptions.IncludeUsage
}
// 如果不支持StreamOptions,将StreamOptions设置为nil
@@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
}
}
if includeUsage {
relayInfo.ShouldIncludeUsage = true
}
relayInfo.ShouldIncludeUsage = includeUsage
adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil {
+3
View File
@@ -258,6 +258,9 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("task_id")
if taskId == "" {
taskId = c.GetString("task_id")
}
userId := c.GetInt("id")
originTask, exist, err := model.GetByTaskId(userId, taskId)
+15 -15
View File
@@ -24,7 +24,7 @@ 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("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
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)
apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth)
@@ -67,7 +67,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
// 2FA routes
selfRoute.GET("/2fa/status", controller.Get2FAStatus)
selfRoute.POST("/2fa/setup", controller.Setup2FA)
@@ -86,7 +86,7 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
// Admin 2FA routes
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
@@ -200,22 +200,22 @@ func SetApiRouter(router *gin.Engine) {
}
vendorRoute := apiRouter.Group("/vendors")
vendorRoute.Use(middleware.AdminAuth())
{
vendorRoute.GET("/", controller.GetAllVendors)
vendorRoute.GET("/search", controller.SearchVendors)
vendorRoute.GET("/:id", controller.GetVendorMeta)
vendorRoute.POST("/", controller.CreateVendorMeta)
vendorRoute.PUT("/", controller.UpdateVendorMeta)
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
}
vendorRoute.Use(middleware.AdminAuth())
{
vendorRoute.GET("/", controller.GetAllVendors)
vendorRoute.GET("/search", controller.SearchVendors)
vendorRoute.GET("/:id", controller.GetVendorMeta)
vendorRoute.POST("/", controller.CreateVendorMeta)
vendorRoute.PUT("/", controller.UpdateVendorMeta)
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
}
modelsRoute := apiRouter.Group("/models")
modelsRoute := apiRouter.Group("/models")
modelsRoute.Use(middleware.AdminAuth())
{
modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta)
modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta)
modelsRoute.GET("/:id", controller.GetModelMeta)
modelsRoute.POST("/", controller.CreateModelMeta)
modelsRoute.PUT("/", controller.UpdateModelMeta)
+38 -4
View File
@@ -1,11 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
"one-api/constant"
"one-api/controller"
"one-api/middleware"
"one-api/relay"
"github.com/gin-gonic/gin"
)
func SetRelayRouter(router *gin.Engine) {
@@ -16,9 +16,43 @@ func SetRelayRouter(router *gin.Engine) {
modelsRouter := router.Group("/v1/models")
modelsRouter.Use(middleware.TokenAuth())
{
modelsRouter.GET("", controller.ListModels)
modelsRouter.GET("/:model", controller.RetrieveModel)
modelsRouter.GET("", func(c *gin.Context) {
switch {
case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "":
controller.ListModels(c, constant.ChannelTypeAnthropic)
case c.GetHeader("x-goog-api-key") != "" || c.Query("key") != "": // 单独的适配
controller.RetrieveModel(c, constant.ChannelTypeGemini)
default:
controller.ListModels(c, constant.ChannelTypeOpenAI)
}
})
modelsRouter.GET("/:model", func(c *gin.Context) {
switch {
case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "":
controller.RetrieveModel(c, constant.ChannelTypeAnthropic)
default:
controller.RetrieveModel(c, constant.ChannelTypeOpenAI)
}
})
}
geminiRouter := router.Group("/v1beta/models")
geminiRouter.Use(middleware.TokenAuth())
{
geminiRouter.GET("", func(c *gin.Context) {
controller.ListModels(c, constant.ChannelTypeGemini)
})
}
geminiCompatibleRouter := router.Group("/v1beta/openai/models")
geminiCompatibleRouter.Use(middleware.TokenAuth())
{
geminiCompatibleRouter.GET("", func(c *gin.Context) {
controller.ListModels(c, constant.ChannelTypeOpenAI)
})
}
playgroundRouter := router.Group("/pg")
playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute())
{
+8
View File
@@ -23,4 +23,12 @@ func SetVideoRouter(router *gin.Engine) {
klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask)
klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTask)
}
// Jimeng official API routes - direct mapping to official API format
jimengOfficialGroup := router.Group("jimeng")
jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
{
// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
jimengOfficialGroup.POST("/", controller.RelayTask)
}
}
+2 -2
View File
@@ -569,9 +569,9 @@ func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycomm
}
// 转换工具调用
if len(geminiRequest.Tools) > 0 {
if len(geminiRequest.GetTools()) > 0 {
var tools []dto.ToolCallRequest
for _, tool := range geminiRequest.Tools {
for _, tool := range geminiRequest.GetTools() {
if tool.FunctionDeclarations != nil {
// 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest
functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest)
+3
View File
@@ -12,6 +12,9 @@ var Chats = []map[string]string{
{
"Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}",
},
{
"流畅阅读": "fluentread",
},
{
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
},
+1
View File
@@ -65,6 +65,7 @@ const (
ErrorCodeBadResponse ErrorCode = "bad_response"
ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
ErrorCodeEmptyResponse ErrorCode = "empty_response"
ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
// sql error
ErrorCodeQueryDataError ErrorCode = "query_data_error"
+16 -11
View File
@@ -21,10 +21,11 @@ import React, { lazy, Suspense } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/common/ui/Loading.js';
import User from './pages/User';
import { AuthRedirect, PrivateRoute } from './helpers';
import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers';
import RegisterForm from './components/auth/RegisterForm.js';
import LoginForm from './components/auth/LoginForm.js';
import NotFound from './pages/NotFound';
import Forbidden from './pages/Forbidden';
import Setting from './pages/Setting';
import PasswordResetForm from './components/auth/PasswordResetForm.js';
@@ -72,20 +73,24 @@ function App() {
</Suspense>
}
/>
<Route
path='/forbidden'
element={<Forbidden />}
/>
<Route
path='/console/models'
element={
<PrivateRoute>
<AdminRoute>
<ModelPage />
</PrivateRoute>
</AdminRoute>
}
/>
<Route
path='/console/channel'
element={
<PrivateRoute>
<AdminRoute>
<Channel />
</PrivateRoute>
</AdminRoute>
}
/>
<Route
@@ -107,17 +112,17 @@ function App() {
<Route
path='/console/redemption'
element={
<PrivateRoute>
<AdminRoute>
<Redemption />
</PrivateRoute>
</AdminRoute>
}
/>
<Route
path='/console/user'
element={
<PrivateRoute>
<AdminRoute>
<User />
</PrivateRoute>
</AdminRoute>
}
/>
<Route
@@ -183,11 +188,11 @@ function App() {
<Route
path='/console/setting'
element={
<PrivateRoute>
<AdminRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Setting />
</Suspense>
</PrivateRoute>
</AdminRoute>
}
/>
<Route
+18 -1
View File
@@ -80,6 +80,8 @@ const RegisterForm = () => {
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const logo = getLogo();
const systemName = getSystemName();
@@ -106,6 +108,19 @@ const RegisterForm = () => {
}
}, [status]);
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval); // Clean up on unmount
}, [disableButton, countdown]);
const onWeChatLoginClicked = () => {
setWechatLoading(true);
setShowWeChatLoginModal(true);
@@ -198,6 +213,7 @@ const RegisterForm = () => {
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查你的邮箱!');
setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
} else {
showError(message);
}
@@ -454,9 +470,10 @@ const RegisterForm = () => {
<Button
onClick={sendVerificationCode}
loading={verificationCodeLoading}
disabled={disableButton || verificationCodeLoading}
size="small"
>
{t('获取验证码')}
{disableButton ? `${t('重新发送')} (${countdown})` : t('获取验证码')}
</Button>
}
/>
+1 -1
View File
@@ -41,7 +41,7 @@ const CardTable = ({
}) => {
const isMobile = useIsMobile();
const { t } = useTranslation();
const showSkeleton = useMinimumLoadingTime(loading);
const getRowKey = (record, index) => {
+346 -331
View File
@@ -1,4 +1,23 @@
import React, { useState, useEffect, useCallback } from 'react';
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -15,16 +34,22 @@ import {
Row,
Col,
Divider,
Tooltip,
} from '@douyinfe/semi-ui';
import {
IconCode,
IconPlus,
IconDelete,
IconRefresh,
IconAlertTriangle,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
const generateUniqueId = (() => {
let counter = 0;
return () => `kv_${counter++}`;
})();
const JSONEditor = ({
value = '',
onChange,
@@ -43,24 +68,51 @@ const JSONEditor = ({
}) => {
const { t } = useTranslation();
// 初始化JSON数据
const [jsonData, setJsonData] = useState(() => {
// 初始化时解析JSON数据
// 将对象转换为键值对数组(包含唯一ID
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
if (!obj || typeof obj !== 'object') return [];
const entries = Object.entries(obj);
return entries.map(([key, value], index) => {
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
const prev = prevPairs[index];
const shouldReuseId = prev && prev.key === key;
return {
id: shouldReuseId ? prev.id : generateUniqueId(),
key,
value,
};
});
}, []);
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
const keyValueArrayToObject = useCallback((arr) => {
const result = {};
arr.forEach(item => {
if (item.key) {
result[item.key] = item.value;
}
});
return result;
}, []);
// 初始化键值对数组
const [keyValuePairs, setKeyValuePairs] = useState(() => {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
return parsed;
return objectToKeyValueArray(parsed);
} catch (error) {
return {};
return [];
}
}
if (typeof value === 'object' && value !== null) {
return value;
return objectToKeyValueArray(value);
}
return {};
return [];
});
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
// 手动模式下的本地文本缓冲
const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
@@ -69,22 +121,38 @@ const JSONEditor = ({
// 根据键数量决定默认编辑模式
const [editMode, setEditMode] = useState(() => {
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
const keyCount = Object.keys(parsed).length;
return keyCount > 10 ? 'manual' : 'visual';
} catch (error) {
// JSON无效时默认显示手动编辑模式
return 'manual';
}
}
return 'visual';
});
const [jsonError, setJsonError] = useState('');
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
// 计算重复的键
const duplicateKeys = useMemo(() => {
const keyCount = {};
const duplicates = new Set();
keyValuePairs.forEach(pair => {
if (pair.key) {
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
if (keyCount[pair.key] > 1) {
duplicates.add(pair.key);
}
}
});
return duplicates;
}, [keyValuePairs]);
// 数据同步 - 当value变化时更新键值对数组
useEffect(() => {
try {
let parsed = {};
@@ -93,16 +161,20 @@ const JSONEditor = ({
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
setJsonData(parsed);
// 只在外部值真正改变时更新,避免循环更新
const currentObj = keyValueArrayToObject(keyValuePairs);
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
}
setJsonError('');
} catch (error) {
console.log('JSON解析失败:', error.message);
setJsonError(error.message);
// JSON格式错误时不更新jsonData
}
}, [value]);
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
// 外部 value 变化时,若不在手动模式,则同步手动文本
useEffect(() => {
if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value);
@@ -112,45 +184,47 @@ const JSONEditor = ({
}, [value, editMode]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback((newData) => {
setJsonData(newData);
setJsonError('');
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
const handleVisualChange = useCallback((newPairs) => {
setKeyValuePairs(newPairs);
const jsonObject = keyValueArrayToObject(newPairs);
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
// 通过formApi设置值(如果提供的话)
setJsonError('');
// 通过formApi设置值
if (formApi && field) {
formApi.setValue(field, jsonString);
}
onChange?.(jsonString);
}, [onChange, formApi, field]);
}, [onChange, formApi, field, keyValueArrayToObject]);
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
// 处理手动编辑的数据变化
const handleManualChange = useCallback((newValue) => {
setManualText(newValue);
if (newValue && newValue.trim()) {
try {
JSON.parse(newValue);
const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
// 无效 JSON 时不回传,避免外部值把输入重置
}
} else {
setKeyValuePairs([]);
setJsonError('');
onChange?.('');
}
}, [onChange]);
}, [onChange, objectToKeyValueArray, keyValuePairs]);
// 切换编辑模式
const toggleEditMode = useCallback(() => {
if (editMode === 'visual') {
// 从可视化模式切换到手动模式
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
const jsonObject = keyValueArrayToObject(keyValuePairs);
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
setEditMode('manual');
} else {
// 从手动模式切换到可视化模式,需要验证JSON
try {
let parsed = {};
if (manualText && manualText.trim()) {
@@ -160,98 +234,166 @@ const JSONEditor = ({
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
setJsonData(parsed);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
setEditMode('visual');
} catch (error) {
setJsonError(error.message);
// JSON格式错误时不切换模式
return;
}
}
}, [editMode, value, manualText, jsonData]);
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
// 添加键值对
const addKeyValue = useCallback(() => {
const newData = { ...jsonData };
const keys = Object.keys(newData);
const newPairs = [...keyValuePairs];
const existingKeys = newPairs.map(p => p.key);
let counter = 1;
let newKey = `field_${counter}`;
while (newData.hasOwnProperty(newKey)) {
while (existingKeys.includes(newKey)) {
counter += 1;
newKey = `field_${counter}`;
}
newData[newKey] = '';
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
newPairs.push({
id: generateUniqueId(),
key: newKey,
value: ''
});
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 删除键值对
const removeKeyValue = useCallback((keyToRemove) => {
const newData = { ...jsonData };
delete newData[keyToRemove];
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
const removeKeyValue = useCallback((id) => {
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 更新键名
const updateKey = useCallback((oldKey, newKey) => {
if (oldKey === newKey || !newKey) return;
const newData = {};
Object.entries(jsonData).forEach(([k, v]) => {
if (k === oldKey) {
newData[newKey] = v;
} else {
newData[k] = v;
}
});
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
const updateKey = useCallback((id, newKey) => {
const newPairs = keyValuePairs.map(pair =>
pair.id === id ? { ...pair, key: newKey } : pair
);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 更新值
const updateValue = useCallback((key, newValue) => {
const newData = { ...jsonData };
newData[key] = newValue;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
const updateValue = useCallback((id, newValue) => {
const newPairs = keyValuePairs.map(pair =>
pair.id === id ? { ...pair, value: newValue } : pair
);
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 填入模板
const fillTemplate = useCallback(() => {
if (template) {
const templateString = JSON.stringify(template, null, 2);
// 通过formApi设置值(如果提供的话)
if (formApi && field) {
formApi.setValue(field, templateString);
}
// 同步内部与外部值,避免出现杂字符
setManualText(templateString);
setJsonData(template);
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
onChange?.(templateString);
// 清除错误状态
setJsonError('');
}
}, [template, onChange, editMode, formApi, field]);
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
if (typeof jsonData !== 'object' || jsonData === null) {
// 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
return (
<div className="text-center py-6 px-4">
<div className="text-gray-400 mb-2">
<IconCode size={32} />
</div>
<Text type="tertiary" className="text-gray-500 text-sm">
{t('无效的JSON数据,请检查格式')}
<div className="flex items-center">
<Switch
checked={value}
onChange={(newValue) => updateValue(pairId, newValue)}
/>
<Text type="tertiary" className="ml-2">
{value ? t('true') : t('false')}
</Text>
</div>
);
}
const entries = Object.entries(jsonData);
if (valueType === 'number') {
return (
<InputNumber
value={value}
onChange={(newValue) => updateValue(pairId, newValue)}
style={{ width: '100%' }}
placeholder={t('输入数字')}
/>
);
}
if (valueType === 'object' && value !== null) {
// 简化嵌套对象的处理,使用TextArea
return (
<TextArea
rows={2}
value={JSON.stringify(value, null, 2)}
onChange={(txt) => {
try {
const obj = txt.trim() ? JSON.parse(txt) : {};
updateValue(pairId, obj);
} catch {
// 忽略解析错误
}
}}
placeholder={t('输入JSON对象')}
/>
);
}
// 字符串或其他原始类型
return (
<Input
placeholder={t('参数值')}
value={String(value)}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '') {
const num = Number(newValue);
// 检查是否为整数
if (Number.isInteger(num)) {
convertedValue = num;
}
}
updateValue(pairId, convertedValue);
}}
/>
);
};
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
return (
<div className="space-y-1">
{entries.length === 0 && (
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type="warning"
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type="tertiary" size="small">
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className="mb-3"
/>
)}
{keyValuePairs.length === 0 && (
<div className="text-center py-6 px-4">
<Text type="tertiary" className="text-gray-500 text-sm">
{t('暂无数据,点击下方按钮添加键值对')}
@@ -259,29 +401,55 @@ const JSONEditor = ({
</div>
)}
{entries.map(([key, value], index) => (
<Row key={index} gutter={8} align="middle">
<Col span={6}>
<Input
placeholder={t('键名')}
value={key}
onChange={(newKey) => updateKey(key, newKey)}
/>
</Col>
<Col span={16}>
{renderValueInput(key, value)}
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(key)}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
{keyValuePairs.map((pair, index) => {
const isDuplicate = duplicateKeys.has(pair.key);
const isLastDuplicate = isDuplicate &&
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
return (
<Row key={pair.id} gutter={8} align="middle">
<Col span={6}>
<div className="relative">
<Input
placeholder={t('键名')}
value={pair.key}
onChange={(newKey) => updateKey(pair.id, newKey)}
status={isDuplicate ? 'warning' : undefined}
/>
{isDuplicate && (
<Tooltip
content={
isLastDuplicate
? t('这是重复键中的最后一个,其值将被使用')
: t('重复的键名,此值将被后面的同名键覆盖')
}
>
<IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2"
style={{
color: isLastDuplicate ? '#ff7d00' : '#faad14',
fontSize: '14px'
}}
/>
</Tooltip>
)}
</div>
</Col>
<Col span={16}>
{renderValueInput(pair.id, pair.value)}
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
</Col>
</Row>
);
})}
<div className="mt-2 flex justify-center">
<Button
@@ -297,249 +465,96 @@ const JSONEditor = ({
);
};
// 添加嵌套对象
const flattenObject = useCallback((parentKey) => {
const newData = { ...jsonData };
let primitive = '';
const obj = newData[parentKey];
if (obj && typeof obj === 'object') {
const firstKey = Object.keys(obj)[0];
if (firstKey !== undefined) {
const firstVal = obj[firstKey];
if (typeof firstVal !== 'object') primitive = firstVal;
}
}
newData[parentKey] = primitive;
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
const addNestedObject = useCallback((parentKey) => {
const newData = { ...jsonData };
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
newData[parentKey] = {};
}
const existingKeys = Object.keys(newData[parentKey]);
let counter = 1;
let newKey = `field_${counter}`;
while (newData[parentKey].hasOwnProperty(newKey)) {
counter += 1;
newKey = `field_${counter}`;
}
newData[parentKey][newKey] = '';
handleVisualChange(newData);
}, [jsonData, handleVisualChange]);
// 渲染参数值输入控件(支持嵌套)
const renderValueInput = (key, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
return (
<div className="flex items-center">
<Switch
checked={value}
onChange={(newValue) => updateValue(key, newValue)}
/>
<Text type="tertiary" className="ml-2">
{value ? t('true') : t('false')}
</Text>
</div>
);
}
if (valueType === 'number') {
return (
<InputNumber
value={value}
onChange={(newValue) => updateValue(key, newValue)}
style={{ width: '100%' }}
step={key === 'temperature' ? 0.1 : 1}
precision={key === 'temperature' ? 2 : 0}
placeholder={t('输入数字')}
/>
);
}
if (valueType === 'object' && value !== null) {
// 渲染嵌套对象
const entries = Object.entries(value);
return (
<Card className="!rounded-2xl">
{entries.length === 0 && (
<Text type="tertiary" className="text-gray-500 text-xs">
{t('空对象,点击下方加号添加字段')}
</Text>
)}
{entries.map(([nestedKey, nestedValue], index) => (
<Row key={index} gutter={4} align="middle" className="mb-1">
<Col span={8}>
<Input
size="small"
placeholder={t('键名')}
value={nestedKey}
onChange={(newKey) => {
const newData = { ...jsonData };
const oldValue = newData[key][nestedKey];
delete newData[key][nestedKey];
newData[key][newKey] = oldValue;
handleVisualChange(newData);
}}
/>
</Col>
<Col span={14}>
{typeof nestedValue === 'object' && nestedValue !== null ? (
<TextArea
size="small"
rows={2}
value={JSON.stringify(nestedValue, null, 2)}
onChange={(txt) => {
try {
const obj = txt.trim() ? JSON.parse(txt) : {};
const newData = { ...jsonData };
newData[key][nestedKey] = obj;
handleVisualChange(newData);
} catch {
// ignore parse error
}
}}
/>
) : (
<Input
size="small"
placeholder={t('值')}
value={String(nestedValue)}
onChange={(newValue) => {
const newData = { ...jsonData };
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
newData[key][nestedKey] = convertedValue;
handleVisualChange(newData);
}}
/>
)}
</Col>
<Col span={2}>
<Button
size="small"
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => {
const newData = { ...jsonData };
delete newData[key][nestedKey];
handleVisualChange(newData);
}}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
<div className="flex justify-center mt-1 gap-2">
<Button
size="small"
icon={<IconPlus />}
type="tertiary"
onClick={() => addNestedObject(key)}
>
{t('添加字段')}
</Button>
<Button
size="small"
icon={<IconRefresh />}
type="tertiary"
onClick={() => flattenObject(key)}
>
{t('转换为值')}
</Button>
</div>
</Card>
);
}
// 字符串或其他原始类型
return (
<div className="flex items-center gap-1">
<Input
placeholder={t('参数值')}
value={String(value)}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
convertedValue = Number(newValue);
}
updateValue(key, convertedValue);
}}
/>
<Button
icon={<IconPlus />}
type="tertiary"
onClick={() => {
// 将当前值转换为对象
const newData = { ...jsonData };
newData[key] = { '1': value };
handleVisualChange(newData);
}}
title={t('转换为对象')}
/>
</div>
);
};
// 渲染区域编辑器(特殊格式)
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
const renderRegionEditor = () => {
const entries = Object.entries(jsonData);
const defaultEntry = entries.find(([key]) => key === 'default');
const modelEntries = entries.filter(([key]) => key !== 'default');
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
return (
<div className="space-y-2">
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type="warning"
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type="tertiary" size="small">
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className="mb-3"
/>
)}
{/* 默认区域 */}
<Form.Slot label={t('默认区域')}>
<Input
placeholder={t('默认区域,如: us-central1')}
value={defaultEntry ? defaultEntry[1] : ''}
onChange={(value) => updateValue('default', value)}
value={defaultPair ? defaultPair.value : ''}
onChange={(value) => {
if (defaultPair) {
updateValue(defaultPair.id, value);
} else {
const newPairs = [...keyValuePairs, {
id: generateUniqueId(),
key: 'default',
value: value
}];
handleVisualChange(newPairs);
}
}}
/>
</Form.Slot>
{/* 模型专用区域 */}
<Form.Slot label={t('模型专用区域')}>
<div>
{modelEntries.map(([modelName, region], index) => (
<Row key={index} gutter={8} align="middle" className="mb-2">
<Col span={10}>
<Input
placeholder={t('模型名称')}
value={modelName}
onChange={(newKey) => updateKey(modelName, newKey)}
/>
</Col>
<Col span={12}>
<Input
placeholder={t('区域')}
value={region}
onChange={(newValue) => updateValue(modelName, newValue)}
/>
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(modelName)}
style={{ width: '100%' }}
/>
</Col>
</Row>
))}
{modelPairs.map((pair) => {
const isDuplicate = duplicateKeys.has(pair.key);
return (
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
<Col span={10}>
<div className="relative">
<Input
placeholder={t('模型名称')}
value={pair.key}
onChange={(newKey) => updateKey(pair.id, newKey)}
status={isDuplicate ? 'warning' : undefined}
/>
{isDuplicate && (
<Tooltip content={t('重复的键名')}>
<IconAlertTriangle
className="absolute right-2 top-1/2 transform -translate-y-1/2"
style={{ color: '#faad14', fontSize: '14px' }}
/>
</Tooltip>
)}
</div>
</Col>
<Col span={12}>
<Input
placeholder={t('区域')}
value={pair.value}
onChange={(newValue) => updateValue(pair.id, newValue)}
/>
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type="danger"
theme="borderless"
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
</Col>
</Row>
);
})}
<div className="mt-2 flex justify-center">
<Button
@@ -666,4 +681,4 @@ const JSONEditor = ({
);
};
export default JSONEditor;
export default JSONEditor;
@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useRef } from 'react';
import React, { useState } from 'react';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui';
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
/**
@@ -57,8 +57,6 @@ const SelectableButtonGroup = ({
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
const showSkeleton = useMinimumLoadingTime(loading);
const contentRef = useRef(null);
const maskStyle = isOpen
? {}
: {
@@ -131,7 +129,7 @@ const SelectableButtonGroup = ({
};
const contentElement = showSkeleton ? renderSkeletonButtons() : (
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }}>
{items.map((item) => {
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
const isActive = Array.isArray(activeValue)
@@ -152,6 +150,7 @@ const SelectableButtonGroup = ({
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled}
className="sbg-button"
icon={
<Checkbox
checked={isActive}
@@ -162,19 +161,15 @@ const SelectableButtonGroup = ({
}
style={{ width: '100%', cursor: 'default' }}
>
{item.icon && (
<span style={{ marginRight: 4 }}>{item.icon}</span>
)}
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
{item.tagCount !== undefined && (
<Tag
color='white'
shape="circle"
size="small"
>
{item.tagCount}
</Tag>
)}
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<Tooltip content={item.label}>
<span className="sbg-ellipsis">{item.label}</span>
</Tooltip>
{item.tagCount !== undefined && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
)}
</div>
</Button>
</Col>
);
@@ -192,20 +187,19 @@ const SelectableButtonGroup = ({
onClick={() => onChange(item.value)}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
icon={item.icon}
disabled={isDisabled}
className="sbg-button"
style={{ width: '100%' }}
>
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
{item.tagCount !== undefined && (
<Tag
color='white'
shape="circle"
size="small"
>
{item.tagCount}
</Tag>
)}
<div className="sbg-content">
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
<Tooltip content={item.label}>
<span className="sbg-ellipsis">{item.label}</span>
</Tooltip>
{item.tagCount !== undefined && (
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
)}
</div>
</Button>
</Col>
);
+8
View File
@@ -201,12 +201,20 @@ const SiderBar = ({ onNavigate = () => { } }) => {
if (Array.isArray(chats)) {
let chatItems = [];
for (let i = 0; i < chats.length; i++) {
let shouldSkip = false;
let chat = {};
for (let key in chats[i]) {
let link = chats[i][key];
if (typeof link !== 'string') continue; // 确保链接是字符串
if (link.startsWith('fluent')) {
shouldSkip = true;
break; // 跳过 Fluent Read
}
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/console/chat/' + i;
}
if (shouldSkip || !chat.text) continue; // 避免推入空项
chatItems.push(chat);
}
setChatItems(chatItems);
+16 -2
View File
@@ -85,6 +85,7 @@ const SystemSetting = () => {
LinuxDOOAuthEnabled: '',
LinuxDOClientId: '',
LinuxDOClientSecret: '',
LinuxDOMinimumTrustLevel: '',
ServerAddress: '',
});
@@ -472,6 +473,12 @@ const SystemSetting = () => {
value: inputs.LinuxDOClientSecret,
});
}
if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) {
options.push({
key: 'LinuxDOMinimumTrustLevel',
value: inputs.LinuxDOMinimumTrustLevel,
});
}
if (options.length > 0) {
await updateOptions(options);
@@ -916,14 +923,14 @@ const SystemSetting = () => {
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
<Form.Input
field='LinuxDOClientId'
label={t('Linux DO Client ID')}
placeholder={t('输入你注册的 LinuxDO OAuth APP 的 ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
<Form.Input
field='LinuxDOClientSecret'
label={t('Linux DO Client Secret')}
@@ -931,6 +938,13 @@ const SystemSetting = () => {
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
<Col xs={24} sm={24} md={4} lg={4} xl={4}>
<Form.Input
field='LinuxDOMinimumTrustLevel'
label='LinuxDO Minimum Trust Level'
placeholder='允许注册的最低信任等级'
/>
</Col>
</Row>
<Button onClick={submitLinuxDOOAuth}>
{t('保存 Linux DO OAuth 设置')}
@@ -544,7 +544,7 @@ export const getChannelsColumns = ({
menu={[
{
node: 'item',
name: t('多key管理'),
name: t('多密钥管理'),
onClick: () => {
setCurrentMultiKeyChannel(record);
setShowMultiKeyManageModal(true);
@@ -132,6 +132,7 @@ const EditChannelModal = (props) => {
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
settings: '',
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -187,38 +188,31 @@ const EditChannelModal = (props) => {
handleInputChange('setting', settingsJson);
};
// JSON
const parseChannelSettings = (settingJson) => {
try {
if (settingJson && settingJson.trim()) {
const parsed = JSON.parse(settingJson);
setChannelSettings({
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
proxy: parsed.proxy || '',
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
system_prompt: parsed.system_prompt || '',
});
} else {
setChannelSettings({
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
});
}
} catch (error) {
console.error('解析渠道设置失败:', error);
setChannelSettings({
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
});
const handleChannelOtherSettingsChange = (key, value) => {
//
setChannelSettings(prev => ({ ...prev, [key]: value }));
//
if (formApiRef.current) {
formApiRef.current.setValue(key, value);
}
};
// inputs
setInputs(prev => ({ ...prev, [key]: value }));
// settingsjson{"azure_responses_version": "preview"}
let settings = {};
if (inputs.settings) {
try {
settings = JSON.parse(inputs.settings);
} catch (error) {
console.error('解析设置失败:', error);
}
}
settings[key] = value;
const settingsJson = JSON.stringify(settings);
handleInputChange('settings', settingsJson);
}
const handleInputChange = (name, value) => {
if (formApiRef.current) {
@@ -360,6 +354,17 @@ const EditChannelModal = (props) => {
data.system_prompt_override = false;
}
if (data.settings) {
try {
const parsedSettings = JSON.parse(data.settings);
data.azure_responses_version = parsedSettings.azure_responses_version || '';
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
}
}
setInputs(data);
if (formApiRef.current) {
formApiRef.current.setValues(data);
@@ -587,6 +592,8 @@ const EditChannelModal = (props) => {
if (formApiRef.current) {
formApiRef.current.setValue('key_mode', undefined);
}
// JSON
setInputs(getInitValues());
}
}, [props.visible, channelId]);
@@ -1257,6 +1264,7 @@ const EditChannelModal = (props) => {
{inputs.type === 41 && (
<JSONEditor
key={`region-${isEdit ? channelId : 'new'}`}
field='other'
label={t('部署地区')}
placeholder={t(
@@ -1374,6 +1382,15 @@ const EditChannelModal = (props) => {
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t('默认 Responses API 版本,为空则使用上方版本')}
placeholder={t('例如:preview')}
onChange={(value) => handleChannelOtherSettingsChange('azure_responses_version', value)}
showClear
/>
</div>
</>
)}
@@ -1552,6 +1569,7 @@ const EditChannelModal = (props) => {
/>
<JSONEditor
key={`model_mapping-${isEdit ? channelId : 'new'}`}
field='model_mapping'
label={t('模型重定向')}
placeholder={
@@ -1655,6 +1673,7 @@ const EditChannelModal = (props) => {
/>
<JSONEditor
key={`status_code_mapping-${isEdit ? channelId : 'new'}`}
field='status_code_mapping'
label={t('状态码复写')}
placeholder={
@@ -135,7 +135,7 @@ const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCa
const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
return (
<Collapse activeKey={allActiveKeys}>
<Collapse key={`${categoryKeyPrefix}_${categoryEntries.length}`} defaultActiveKey={[]}>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${categoryKeyPrefix}_${index}`}
@@ -30,20 +30,17 @@ import {
Popconfirm,
Empty,
Spin,
Banner,
Select,
Pagination
Row,
Col,
Badge,
Progress,
Card
} from '@douyinfe/semi-ui';
import {
IconRefresh,
IconDelete,
IconClose,
IconSave,
IconSetting
} from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers/index.js';
const { Text, Title } = Typography;
const { Text } = Typography;
const MultiKeyManageModal = ({
visible,
@@ -55,13 +52,13 @@ const MultiKeyManageModal = ({
const [loading, setLoading] = useState(false);
const [keyStatusList, setKeyStatusList] = useState([]);
const [operationLoading, setOperationLoading] = useState({});
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
// Statistics states
const [enabledCount, setEnabledCount] = useState(0);
const [manualDisabledCount, setManualDisabledCount] = useState(0);
@@ -73,7 +70,7 @@ const MultiKeyManageModal = ({
// Load key status data
const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
if (!channel?.id) return;
setLoading(true);
try {
const requestData = {
@@ -82,22 +79,22 @@ const MultiKeyManageModal = ({
page: page,
page_size: size
};
// Add status filter if specified
if (status !== null) {
requestData.status = status;
}
const res = await API.post('/api/channel/multi_key/manage', requestData);
if (res.data.success) {
const data = res.data.data;
setKeyStatusList(data.keys || []);
setTotal(data.total || 0);
setCurrentPage(data.page || 1);
setPageSize(data.page_size || 50);
setPageSize(data.page_size || 10);
setTotalPages(data.total_pages || 0);
// Update statistics (these are always the overall statistics)
setEnabledCount(data.enabled_count || 0);
setManualDisabledCount(data.manual_disabled_count || 0);
@@ -117,14 +114,14 @@ const MultiKeyManageModal = ({
const handleDisableKey = async (keyIndex) => {
const operationId = `disable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_key',
key_index: keyIndex
});
if (res.data.success) {
showSuccess(t('密钥已禁用'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
@@ -143,14 +140,14 @@ const MultiKeyManageModal = ({
const handleEnableKey = async (keyIndex) => {
const operationId = `enable_${keyIndex}`;
setOperationLoading(prev => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_key',
key_index: keyIndex
});
if (res.data.success) {
showSuccess(t('密钥已启用'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
@@ -168,13 +165,13 @@ const MultiKeyManageModal = ({
// Enable all disabled keys
const handleEnableAll = async () => {
setOperationLoading(prev => ({ ...prev, enable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'enable_all_keys'
});
if (res.data.success) {
showSuccess(res.data.message || t('已启用所有密钥'));
// Reset to first page after bulk operation
@@ -194,13 +191,13 @@ const MultiKeyManageModal = ({
// Disable all enabled keys
const handleDisableAll = async () => {
setOperationLoading(prev => ({ ...prev, disable_all: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'disable_all_keys'
});
if (res.data.success) {
showSuccess(res.data.message || t('已禁用所有密钥'));
// Reset to first page after bulk operation
@@ -220,13 +217,13 @@ const MultiKeyManageModal = ({
// Delete all disabled keys
const handleDeleteDisabledKeys = async () => {
setOperationLoading(prev => ({ ...prev, delete_disabled: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'delete_disabled_keys'
});
if (res.data.success) {
showSuccess(res.data.message);
// Reset to first page after deletion as data structure might change
@@ -285,17 +282,24 @@ const MultiKeyManageModal = ({
}
}, [visible]);
// Percentages for progress display
const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0;
const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
//
// Get status tag component
const renderStatusTag = (status) => {
switch (status) {
case 1:
return <Tag color='green' shape='circle'>{t('已启用')}</Tag>;
return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>;
case 2:
return <Tag color='red' shape='circle'>{t('已禁用')}</Tag>;
return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>;
case 3:
return <Tag color='orange' shape='circle'>{t('自动禁用')}</Tag>;
return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>;
default:
return <Tag color='grey' shape='circle'>{t('未知状态')}</Tag>;
return <Tag color='grey' shape='circle' size='small'>{t('未知状态')}</Tag>;
}
};
@@ -318,13 +322,11 @@ const MultiKeyManageModal = ({
{
title: t('状态'),
dataIndex: 'status',
width: 100,
render: (status) => renderStatusTag(status),
},
{
title: t('禁用原因'),
dataIndex: 'reason',
width: 220,
render: (reason, record) => {
if (record.status === 1 || !reason) {
return <Text type='quaternary'>-</Text>;
@@ -341,7 +343,6 @@ const MultiKeyManageModal = ({
{
title: t('禁用时间'),
dataIndex: 'disabled_time',
width: 150,
render: (time, record) => {
if (record.status === 1 || !time) {
return <Text type='quaternary'>-</Text>;
@@ -358,7 +359,8 @@ const MultiKeyManageModal = ({
{
title: t('操作'),
key: 'action',
width: 120,
fixed: 'right',
width: 100,
render: (_, record) => (
<Space>
{record.status === 1 ? (
@@ -389,196 +391,194 @@ const MultiKeyManageModal = ({
<Modal
title={
<Space>
<IconSetting />
<span>{t('多密钥管理')} - {channel?.name}</span>
<Text>{t('多密钥管理')}</Text>
{channel?.name && (
<Tag size='small' shape='circle' color='white'>{channel.name}</Tag>
)}
<Tag size='small' shape='circle' color='white'>
{t('总密钥数')}: {total}
</Tag>
{channel?.channel_info?.multi_key_mode && (
<Tag size='small' shape='circle' color='white'>
{channel.channel_info.multi_key_mode === 'random' ? t('随机模式') : t('轮询模式')}
</Tag>
)}
</Space>
}
visible={visible}
onCancel={onCancel}
width={900}
footer={
<Space>
<Button onClick={onCancel}>{t('关闭')}</Button>
<Button
icon={<IconRefresh />}
onClick={() => loadKeyStatus(currentPage, pageSize)}
loading={loading}
>
{t('刷新')}
</Button>
<Popconfirm
title={t('确定要启用所有密钥吗?')}
onConfirm={handleEnableAll}
position={'topRight'}
>
<Button
type='primary'
loading={operationLoading.enable_all}
>
{t('启用全部')}
</Button>
</Popconfirm>
{enabledCount > 0 && (
<Popconfirm
title={t('确定要禁用所有的密钥吗?')}
onConfirm={handleDisableAll}
okType={'danger'}
position={'topRight'}
>
<Button
type='danger'
loading={operationLoading.disable_all}
>
{t('禁用全部')}
</Button>
</Popconfirm>
)}
<Popconfirm
title={t('确定要删除所有已自动禁用的密钥吗?')}
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
onConfirm={handleDeleteDisabledKeys}
okType={'danger'}
position={'topRight'}
>
<Button
type='danger'
icon={<IconDelete />}
loading={operationLoading.delete_disabled}
>
{t('删除自动禁用密钥')}
</Button>
</Popconfirm>
</Space>
}
footer={null}
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Statistics Banner */}
<Banner
type='info'
style={{ marginBottom: '16px', flexShrink: 0 }}
description={
<div>
<Text>
{t('总共 {{total}} 个密钥,{{enabled}} 个已启用,{{manual}} 个手动禁用,{{auto}} 个自动禁用', {
total: total,
enabled: enabledCount,
manual: manualDisabledCount,
auto: autoDisabledCount
})}
</Text>
{channel?.channel_info?.multi_key_mode && (
<div style={{ marginTop: '4px' }}>
<Text type='quaternary' style={{ fontSize: '12px' }}>
{t('多密钥模式')}: {channel.channel_info.multi_key_mode === 'random' ? t('随机') : t('轮询')}
</Text>
<div className="flex flex-col mb-5">
{/* Stats & Mode */}
<div
className="rounded-xl p-4 mb-3"
style={{
background: 'var(--semi-color-bg-1)',
border: '1px solid var(--semi-color-border)'
}}
>
<Row gutter={16} align="middle">
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<Badge dot type='success' />
<Text type='tertiary'>{t('已启用')}</Text>
</div>
)}
</div>
}
/>
{/* Filter Controls */}
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0 }}>
<Text style={{ fontSize: '14px', fontWeight: '500' }}>{t('状态筛选')}:</Text>
<Select
value={statusFilter}
onChange={handleStatusFilterChange}
style={{ width: '120px' }}
size='small'
placeholder={t('全部状态')}
>
<Select.Option value={null}>{t('全部状态')}</Select.Option>
<Select.Option value={1}>{t('已启用')}</Select.Option>
<Select.Option value={2}>{t('手动禁用')}</Select.Option>
<Select.Option value={3}>{t('自动禁用')}</Select.Option>
</Select>
{statusFilter !== null && (
<Text type='quaternary' style={{ fontSize: '12px' }}>
{t('当前显示 {{count}} 条筛选结果', { count: total })}
</Text>
)}
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
</div>
<Progress percent={enabledPercent} showInfo={false} size="small" stroke="#22c55e" style={{ height: 6, borderRadius: 999 }} />
</div>
</Col>
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<Badge dot type='danger' />
<Text type='tertiary'>{t('手动禁用')}</Text>
</div>
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
</div>
<Progress percent={manualDisabledPercent} showInfo={false} size="small" stroke="#ef4444" style={{ height: 6, borderRadius: 999 }} />
</div>
</Col>
<Col span={8}>
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
<div className="flex items-center gap-2 mb-2">
<Badge dot type='warning' />
<Text type='tertiary'>{t('自动禁用')}</Text>
</div>
<div className="flex items-end gap-2 mb-2">
<Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text>
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
</div>
<Progress percent={autoDisabledPercent} showInfo={false} size="small" stroke="#f59e0b" style={{ height: 6, borderRadius: 999 }} />
</div>
</Col>
</Row>
</div>
{/* Key Status Table */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* Table */}
<div className="flex-1 flex flex-col min-h-0">
<Spin spinning={loading}>
{keyStatusList.length > 0 ? (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto', marginBottom: '16px' }}>
<Table
columns={columns}
dataSource={keyStatusList}
pagination={false}
size='small'
bordered
rowKey='index'
scroll={{ y: 'calc(100vh - 400px)' }}
/>
</div>
{/* Pagination */}
{total > 0 && (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
padding: '12px 0',
borderTop: '1px solid var(--semi-color-border)',
backgroundColor: 'var(--semi-color-bg-1)'
}}>
<Text type='quaternary' style={{ fontSize: '12px' }}>
{t('显示第 {{start}}-{{end}} 条,共 {{total}} 条', {
start: (currentPage - 1) * pageSize + 1,
end: Math.min(currentPage * pageSize, total),
total: total
})}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Text type='quaternary' style={{ fontSize: '12px' }}>
{t('每页显示')}:
</Text>
<Select
value={pageSize}
onChange={handlePageSizeChange}
size='small'
style={{ width: '80px' }}
>
<Select.Option value={50}>50</Select.Option>
<Select.Option value={100}>100</Select.Option>
<Select.Option value={500}>500</Select.Option>
<Select.Option value={1000}>1000</Select.Option>
</Select>
<Pagination
current={currentPage}
total={total}
pageSize={pageSize}
showSizeChanger={false}
showQuickJumper
size='small'
onChange={handlePageChange}
showTotal={(total, range) =>
t('第 {{current}} / {{total}} 页', {
current: currentPage,
total: totalPages
})
}
/>
</div>
</div>
<Card className='!rounded-xl'>
<Table
title={() => (
<Row gutter={12} style={{ width: '100%' }}>
<Col span={14}>
<Row gutter={12} style={{ alignItems: 'center' }}>
<Col>
<Select
value={statusFilter}
onChange={handleStatusFilterChange}
size='small'
placeholder={t('全部状态')}
>
<Select.Option value={null}>{t('全部状态')}</Select.Option>
<Select.Option value={1}>{t('已启用')}</Select.Option>
<Select.Option value={2}>{t('手动禁用')}</Select.Option>
<Select.Option value={3}>{t('自动禁用')}</Select.Option>
</Select>
</Col>
</Row>
</Col>
<Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button
size='small'
type='tertiary'
onClick={() => loadKeyStatus(currentPage, pageSize)}
loading={loading}
>
{t('刷新')}
</Button>
{(manualDisabledCount + autoDisabledCount) > 0 && (
<Popconfirm
title={t('确定要启用所有密钥吗?')}
onConfirm={handleEnableAll}
position={'topRight'}
>
<Button
size='small'
type='primary'
loading={operationLoading.enable_all}
>
{t('启用全部')}
</Button>
</Popconfirm>
)}
{enabledCount > 0 && (
<Popconfirm
title={t('确定要禁用所有的密钥吗?')}
onConfirm={handleDisableAll}
okType={'danger'}
position={'topRight'}
>
<Button
size='small'
type='danger'
loading={operationLoading.disable_all}
>
{t('禁用全部')}
</Button>
</Popconfirm>
)}
<Popconfirm
title={t('确定要删除所有已自动禁用的密钥吗?')}
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
onConfirm={handleDeleteDisabledKeys}
okType={'danger'}
position={'topRight'}
>
<Button
size='small'
type='warning'
loading={operationLoading.delete_disabled}
>
{t('删除自动禁用密钥')}
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
)}
</div>
) : (
!loading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
title={t('暂无密钥数据')}
description={t('请检查渠道配置或刷新重试')}
/>
)
)}
columns={columns}
dataSource={keyStatusList}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOpts: [10, 20, 50, 100],
onChange: (page, size) => {
setCurrentPage(page);
loadKeyStatus(page, size);
},
onShowSizeChange: (current, size) => {
setCurrentPage(1);
handlePageSizeChange(size);
}
}}
size='small'
bordered={false}
rowKey='index'
scroll={{ x: 'max-content' }}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 140, height: 140 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />}
title={t('暂无密钥数据')}
description={t('请检查渠道配置或刷新重试')}
style={{ padding: 30 }}
/>
}
/>
</Card>
</Spin>
</div>
</div>
@@ -0,0 +1,100 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 模型标签筛选组件
* @param {string|'all'} filterTag 当前选中的标签
* @param {Function} setFilterTag setter
* @param {Array} models 当前过滤后模型列表用于计数
* @param {Array} allModels 所有模型列表用于获取所有标签
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingTags = ({ filterTag, setFilterTag, models = [], allModels = [], loading = false, t }) => {
//
const getAllTags = React.useMemo(() => {
const tagSet = new Set();
(allModels.length > 0 ? allModels : models).forEach(model => {
if (model.tags) {
model.tags
.split(/[,;|\s]+/) // 线
.map(tag => tag.trim())
.filter(Boolean)
.forEach(tag => tagSet.add(tag.toLowerCase()));
}
});
return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
}, [allModels, models]);
//
const getTagCount = React.useCallback((tag) => {
if (tag === 'all') return models.length;
const tagLower = tag.toLowerCase();
return models.filter(model => {
if (!model.tags) return false;
return model.tags
.toLowerCase()
.split(/[,;|\s]+/)
.map(tg => tg.trim())
.includes(tagLower);
}).length;
}, [models]);
const items = React.useMemo(() => {
const result = [
{
value: 'all',
label: t('全部标签'),
tagCount: getTagCount('all'),
disabled: models.length === 0,
}
];
getAllTags.forEach(tag => {
const count = getTagCount(tag);
result.push({
value: tag,
label: tag,
tagCount: count,
disabled: count === 0,
});
});
return result;
}, [getAllTags, getTagCount, t, models.length]);
return (
<SelectableButtonGroup
title={t('标签')}
items={items}
activeValue={filterTag}
onChange={setFilterTag}
loading={loading}
t={t}
/>
);
};
export default PricingTags;
@@ -23,6 +23,7 @@ import PricingGroups from '../filter/PricingGroups';
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
import PricingVendors from '../filter/PricingVendors';
import PricingTags from '../filter/PricingTags';
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
import { resetPricingFilters } from '../../../../helpers/utils';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
@@ -47,6 +48,8 @@ const PricingSidebar = ({
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
currentPage,
setCurrentPage,
tokenUnit,
@@ -60,6 +63,7 @@ const PricingSidebar = ({
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
@@ -67,6 +71,7 @@ const PricingSidebar = ({
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: categoryProps.searchValue,
});
@@ -81,6 +86,7 @@ const PricingSidebar = ({
setFilterQuotaType,
setFilterEndpointType,
setFilterVendor,
setFilterTag,
setCurrentPage,
setTokenUnit,
});
@@ -125,6 +131,15 @@ const PricingSidebar = ({
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
@@ -50,6 +50,7 @@ const PricingTopSection = ({
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
style={{ backgroundColor: 'transparent' }}
/>
</div>
@@ -81,14 +82,16 @@ const PricingTopSection = ({
return (
<>
{/* 供应商介绍区域(含骨架屏 */}
<PricingVendorIntroWithSkeleton
loading={loading}
filterVendor={filterVendor}
models={filteredModels}
allModels={models}
t={t}
/>
{/* 供应商介绍区域(桌面端显示 */}
{!isMobile && (
<PricingVendorIntroWithSkeleton
loading={loading}
filterVendor={filterVendor}
models={filteredModels}
allModels={models}
t={t}
/>
)}
{/* 搜索和操作区域 */}
{SearchAndActions}
@@ -166,7 +166,7 @@ const PricingVendorIntro = ({
if (filterVendor === 'all') {
return (
<div className='mb-4'>
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
<div className="flex items-start space-x-3 md:space-x-4">
{/* 全部供应商的头像组合 */}
<div className="flex-shrink-0">
@@ -210,7 +210,7 @@ const PricingVendorIntro = ({
return (
<div className='mb-4'>
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
<div className="flex items-start space-x-3 md:space-x-4">
{/* 供应商图标 */}
<div className="flex-shrink-0">
@@ -25,7 +25,7 @@ const PricingVendorIntroSkeleton = ({
}) => {
const placeholder = (
<div className='mb-4'>
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
<div className="flex items-start space-x-3 md:space-x-4">
{/* 供应商图标骨架 */}
<div className="flex-shrink-0 min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
@@ -40,6 +40,7 @@ const PricingFilterModal = ({
setFilterQuotaType: sidebarProps.setFilterQuotaType,
setFilterEndpointType: sidebarProps.setFilterEndpointType,
setFilterVendor: sidebarProps.setFilterVendor,
setFilterTag: sidebarProps.setFilterTag,
setCurrentPage: sidebarProps.setCurrentPage,
setTokenUnit: sidebarProps.setTokenUnit,
});
@@ -23,6 +23,7 @@ import PricingGroups from '../../filter/PricingGroups';
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
import PricingVendors from '../../filter/PricingVendors';
import PricingTags from '../../filter/PricingTags';
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
const FilterModalContent = ({ sidebarProps, t }) => {
@@ -45,6 +46,8 @@ const FilterModalContent = ({ sidebarProps, t }) => {
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
tokenUnit,
setTokenUnit,
loading,
@@ -55,6 +58,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
@@ -62,6 +66,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: sidebarProps.searchValue,
});
@@ -91,6 +96,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
@@ -29,9 +29,19 @@ const CARD_STYLES = {
};
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
// 使
//
const getModelIcon = () => {
// 使
// 1) 使
if (modelData?.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(modelData.icon, 32)}
</div>
</div>
);
}
// 2) 退
if (modelData?.vendor_icon) {
return (
<div className={CARD_STYLES.container}>
@@ -63,7 +63,7 @@ const ModelPricingTable = ({
key: group,
group: group,
ratio: groupRatioValue,
billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'),
billingType: modelData?.quota_type === 0 ? t('按量计费') : (modelData?.quota_type === 1 ? t('按次计费') : '-'),
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-',
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
@@ -100,11 +100,16 @@ const ModelPricingTable = ({
columns.push({
title: t('计费类型'),
dataIndex: 'billingType',
render: (text) => (
<Tag color={text === t('按量计费') ? 'violet' : 'teal'} size="small" shape="circle">
{text}
</Tag>
),
render: (text) => {
let color = 'white';
if (text === t('按量计费')) color = 'violet';
else if (text === t('按次计费')) color = 'teal';
return (
<Tag color={color} size="small" shape="circle">
{text || '-'}
</Tag>
);
},
});
//
@@ -26,7 +26,7 @@ const PricingCardSkeleton = ({
showRatio = false
}) => {
const placeholder = (
<div className="p-4">
<div className="px-4">
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{Array.from({ length: skeletonCount }).map((_, index) => (
<Card
@@ -123,7 +123,7 @@ const PricingCardSkeleton = ({
</div>
{/* 分页骨架 */}
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
<div className="flex justify-center mt-6 py-4 border-t pricing-pagination-divider">
<Skeleton.Button style={{ width: 300, height: 32 }} />
</div>
</div>
@@ -81,7 +81,17 @@ const PricingCardView = ({
</div>
);
}
// 使
// 1) 使
if (model.icon) {
return (
<div className={CARD_STYLES.container}>
<div className={CARD_STYLES.icon}>
{getLobeHubIcon(model.icon, 32)}
</div>
</div>
);
}
// 2) 退
if (model.vendor_icon) {
return (
<div className={CARD_STYLES.container}>
@@ -126,7 +136,7 @@ const PricingCardView = ({
groupRatio,
tokenUnit,
displayPrice,
currency
currency,
});
return formatPriceInfo(priceData, t);
};
@@ -134,13 +144,24 @@ const PricingCardView = ({
//
const renderTags = (record) => {
//
const billingType = record.quota_type === 1 ? 'teal' : 'violet';
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
const billingTag = (
<Tag key="billing" shape='circle' color={billingType} size='small'>
{billingText}
let billingTag = (
<Tag key="billing" shape='circle' color='white' size='small'>
-
</Tag>
);
if (record.quota_type === 1) {
billingTag = (
<Tag key="billing" shape='circle' color='teal' size='small'>
{t('按次计费')}
</Tag>
);
} else if (record.quota_type === 0) {
billingTag = (
<Tag key="billing" shape='circle' color='violet' size='small'>
{t('按量计费')}
</Tag>
);
}
//
const customTags = [];
@@ -161,7 +182,7 @@ const PricingCardView = ({
{billingTag}
</div>
<div className="flex items-center gap-1">
{renderLimitedItems({
{customTags.length > 0 && renderLimitedItems({
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
renderItem: (item, idx) => item.element,
maxDisplay: 3
@@ -194,7 +215,7 @@ const PricingCardView = ({
}
return (
<div className="p-4">
<div className="px-4">
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
@@ -292,7 +313,7 @@ const PricingCardView = ({
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
</div>
<div>
{t('分组')}: {groupRatio[selectedGroup]}
{t('分组')}: {priceData.usedGroupRatio}
</div>
</div>
</div>
@@ -306,7 +327,7 @@ const PricingCardView = ({
{/* 分页 */}
{filteredModels.length > 0 && (
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
<div className="flex justify-center mt-6 py-4 border-t pricing-pagination-divider">
<Pagination
currentPage={currentPage}
pageSize={pageSize}
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
import { Button, Space, Tag, Typography, Modal, Tooltip } from '@douyinfe/semi-ui';
import {
timestamp2string,
getLobeHubIcon,
@@ -33,6 +33,17 @@ function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
// Render model icon column: prefer model.icon, then fallback to vendor icon
const renderModelIconCol = (record, vendorMap) => {
const iconKey = record?.icon || vendorMap[record?.vendor_id]?.icon;
if (!iconKey) return '-';
return (
<div className="flex items-center justify-center">
{getLobeHubIcon(iconKey, 20)}
</div>
);
};
// Render vendor column with icon
const renderVendorTag = (vendorId, vendorMap, t) => {
if (!vendorId || !vendorMap[vendorId]) return '-';
@@ -110,23 +121,34 @@ const renderEndpoints = (value) => {
}
};
// Render quota type
const renderQuotaType = (qt, t) => {
if (qt === 1) {
return (
<Tag color='teal' size='small' shape='circle'>
{t('按次计费')}
</Tag>
);
}
if (qt === 0) {
return (
<Tag color='violet' size='small' shape='circle'>
{t('按量计费')}
</Tag>
);
}
return qt ?? '-';
// Render quota types (array) using common limited items renderer
const renderQuotaTypes = (arr, t) => {
if (!Array.isArray(arr) || arr.length === 0) return '-';
return renderLimitedItems({
items: arr,
renderItem: (qt, idx) => {
if (qt === 1) {
return (
<Tag key={`${qt}-${idx}`} color='teal' size='small' shape='circle'>
{t('按次计费')}
</Tag>
);
}
if (qt === 0) {
return (
<Tag key={`${qt}-${idx}`} color='violet' size='small' shape='circle'>
{t('按量计费')}
</Tag>
);
}
return (
<Tag key={`${qt}-${idx}`} color='white' size='small' shape='circle'>
{qt}
</Tag>
);
},
maxDisplay: 3,
});
};
// Render bound channels
@@ -196,8 +218,8 @@ const renderOperations = (text, record, setEditingModel, setShowEdit, manageMode
);
};
// 名称匹配类型渲染
const renderNameRule = (rule, t) => {
// 名称匹配类型渲染(带匹配数量 Tooltip
const renderNameRule = (rule, record, t) => {
const map = {
0: { color: 'green', label: t('精确') },
1: { color: 'blue', label: t('前缀') },
@@ -206,11 +228,27 @@ const renderNameRule = (rule, t) => {
};
const cfg = map[rule];
if (!cfg) return '-';
return (
let label = cfg.label;
if (rule !== 0 && record.matched_count) {
label = `${cfg.label} ${record.matched_count}${t('个模型')}`;
}
const tagElement = (
<Tag color={cfg.color} size="small" shape='circle'>
{cfg.label}
{label}
</Tag>
);
if (rule === 0 || !record.matched_models || record.matched_models.length === 0) {
return tagElement;
}
return (
<Tooltip content={record.matched_models.join(', ')} showArrow>
{tagElement}
</Tooltip>
);
};
export const getModelsColumns = ({
@@ -222,6 +260,13 @@ export const getModelsColumns = ({
vendorMap,
}) => {
return [
{
title: t('图标'),
dataIndex: 'icon',
width: 70,
align: 'center',
render: (text, record) => renderModelIconCol(record, vendorMap),
},
{
title: t('模型名称'),
dataIndex: 'model_name',
@@ -234,7 +279,7 @@ export const getModelsColumns = ({
{
title: t('匹配类型'),
dataIndex: 'name_rule',
render: (val) => renderNameRule(val, t),
render: (val, record) => renderNameRule(val, record, t),
},
{
title: t('描述'),
@@ -268,8 +313,8 @@ export const getModelsColumns = ({
},
{
title: t('计费类型'),
dataIndex: 'quota_type',
render: (qt) => renderQuotaType(qt, t),
dataIndex: 'quota_types',
render: (qts) => renderQuotaTypes(qts, t),
},
{
title: t('创建时间'),
@@ -33,6 +33,7 @@ import {
Row,
} from '@douyinfe/semi-ui';
import { Save, X, FileText } from 'lucide-react';
import { IconLink } from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
@@ -112,6 +113,7 @@ const EditModelModal = (props) => {
const getInitValues = () => ({
model_name: props.editingModel?.model_name || '',
description: '',
icon: '',
tags: [],
vendor_id: undefined,
vendor: '',
@@ -314,6 +316,27 @@ const EditModelModal = (props) => {
/>
</Col>
<Col span={24}>
<Form.Input
field='icon'
label={t('模型图标')}
placeholder={t('请输入图标名称')}
extraText={
<span>
{t('图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'},查询所有可用图标请 ')}
<Typography.Text
link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
icon={<IconLink />}
underline
>
{t('请点击我')}
</Typography.Text>
</span>
}
showClear
/>
</Col>
<Col span={24}>
<Form.TextArea
field='description'
@@ -158,10 +158,10 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
<Form.Input
field="icon"
label={t('供应商图标')}
placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')}
placeholder={t("请输入图标名称")}
extraText={
<span>
{t('图标使用@lobehub/icons库,查询所有可用图标 ')}
{t('图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'}查询所有可用图标 ')}
<Typography.Text
link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
icon={<IconLink />}
@@ -28,7 +28,8 @@ import {
Avatar,
Tooltip,
Progress,
Switch,
Popover,
Typography,
Input,
Modal
} from '@douyinfe/semi-ui';
@@ -46,21 +47,22 @@ import {
IconEyeClosed,
} from '@douyinfe/semi-icons';
// progress color helper
const getProgressColor = (pct) => {
if (pct === 100) return 'var(--semi-color-success)';
if (pct <= 10) return 'var(--semi-color-danger)';
if (pct <= 30) return 'var(--semi-color-warning)';
return undefined;
};
// Render functions
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
// Render status column with switch and progress bar
const renderStatus = (text, record, manageToken, t) => {
// Render status column only (no usage)
const renderStatus = (text, record, t) => {
const enabled = text === 1;
const handleToggle = (checked) => {
if (checked) {
manageToken(record.id, 'enable', record);
} else {
manageToken(record.id, 'disable', record);
}
};
let tagColor = 'black';
let tagText = t('未知状态');
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
tagText = t('已耗尽');
}
const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.remain_quota) || 0;
const total = used + remain;
const percent = total > 0 ? (remain / total) * 100 : 0;
const getProgressColor = (pct) => {
if (pct === 100) return 'var(--semi-color-success)';
if (pct <= 10) return 'var(--semi-color-danger)';
if (pct <= 30) return 'var(--semi-color-warning)';
return undefined;
};
const quotaSuffix = record.unlimited_quota ? (
<div className='text-xs'>{t('无限额度')}</div>
) : (
<div className='flex flex-col items-end'>
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
<Progress
percent={percent}
stroke={getProgressColor(percent)}
aria-label='quota usage'
format={() => `${percent.toFixed(0)}%`}
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
/>
</div>
);
const content = (
<Tag
color={tagColor}
shape='circle'
size='large'
prefixIcon={
<Switch
size='small'
checked={enabled}
onChange={handleToggle}
aria-label='token status switch'
/>
}
suffixIcon={quotaSuffix}
>
return (
<Tag color={tagColor} shape='circle' size='small'>
{tagText}
</Tag>
);
const tooltipContent = record.unlimited_quota ? (
<div className='text-xs'>
<div>{t('已用额度')}: {renderQuota(used)}</div>
</div>
) : (
<div className='text-xs'>
<div>{t('已用额度')}: {renderQuota(used)}</div>
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
<div>{t('总额度')}: {renderQuota(total)}</div>
</div>
);
return (
<Tooltip content={tooltipContent}>
{content}
</Tooltip>
);
};
// Render group column
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
return <Space wrap>{ipTags}</Space>;
};
// Render separate quota usage column
const renderQuotaUsage = (text, record, t) => {
const { Paragraph } = Typography;
const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.remain_quota) || 0;
const total = used + remain;
if (record.unlimited_quota) {
const popoverContent = (
<div className='text-xs p-2'>
<Paragraph copyable={{ content: renderQuota(used) }}>
{t('已用额度')}: {renderQuota(used)}
</Paragraph>
</div>
);
return (
<Popover content={popoverContent} position='top'>
<Tag color='white' shape='circle'>
{t('无限额度')}
</Tag>
</Popover>
);
}
const percent = total > 0 ? (remain / total) * 100 : 0;
const popoverContent = (
<div className='text-xs p-2'>
<Paragraph copyable={{ content: renderQuota(used) }}>
{t('已用额度')}: {renderQuota(used)}
</Paragraph>
<Paragraph copyable={{ content: renderQuota(remain) }}>
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
</Paragraph>
<Paragraph copyable={{ content: renderQuota(total) }}>
{t('总额度')}: {renderQuota(total)}
</Paragraph>
</div>
);
return (
<Popover content={popoverContent} position='top'>
<Tag color='white' shape='circle'>
<div className='flex flex-col items-end'>
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
<Progress
percent={percent}
stroke={getProgressColor(percent)}
aria-label='quota usage'
format={() => `${percent.toFixed(0)}%`}
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
/>
</div>
</Tag>
</Popover>
);
};
// Render operations column
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
let chats = localStorage.getItem('chats');
let chatsArray = [];
let shouldUseCustom = true;
if (shouldUseCustom) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
for (let i = 0; i < chats.length; i++) {
let chat = {};
chat.node = 'item';
for (let key in chats[i]) {
if (chats[i].hasOwnProperty(key)) {
chat.key = i;
chat.name = key;
chat.onClick = () => {
onOpenLink(key, chats[i][key], record);
};
}
}
chatsArray.push(chat);
}
try {
const raw = localStorage.getItem('chats');
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
const name = Object.keys(item)[0];
if (!name) continue;
chatsArray.push({
node: 'item',
key: i,
name,
onClick: () => onOpenLink(name, item[name], record),
});
}
} catch (e) {
console.log(e);
showError(t('聊天链接配置错误,请联系管理员'));
}
} catch (_) {
showError(t('聊天链接配置错误,请联系管理员'));
}
return (
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
} else {
onOpenLink(
'default',
chats[0][Object.keys(chats[0])[0]],
chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
record,
);
}
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
</Dropdown>
</SplitButtonGroup>
{record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={async () => {
await manageToken(record.id, 'disable', record);
await refresh();
}}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={async () => {
await manageToken(record.id, 'enable', record);
await refresh();
}}
>
{t('启用')}
</Button>
)}
<Button
type='tertiary'
size="small"
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record) => renderStatus(text, record, manageToken, t),
render: (text, record) => renderStatus(text, record, t),
},
{
title: t('剩余额度/总额度'),
key: 'quota_usage',
render: (text, record) => renderQuotaUsage(text, record, t),
},
{
title: t('分组'),
+242 -4
View File
@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
import CardPro from '../../common/ui/CardPro';
import TokensTable from './TokensTable.jsx';
import TokensActions from './TokensActions.jsx';
@@ -28,9 +30,245 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
const TokensPage = () => {
const tokensData = useTokensData();
function TokensPage() {
// Define the function first, then pass it into the hook to avoid TDZ errors
const openFluentNotificationRef = useRef(null);
const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
const isMobile = useIsMobile();
const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
const [prefillKey, setPrefillKey] = useState('');
// Keep latest data for handlers inside notifications
useEffect(() => {
latestRef.current = {
tokens: tokensData.tokens,
selectedKeys: tokensData.selectedKeys,
t: tokensData.t,
selectedModel,
prefillKey,
};
}, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
const loadModels = async () => {
try {
const res = await API.get('/api/user/models');
const { success, message, data } = res.data || {};
if (success) {
const categories = getModelCategories(tokensData.t);
const options = (data || []).map((model) => {
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
icon = category.icon;
break;
}
}
return {
label: (
<span className="flex items-center gap-1">
{icon}
{model}
</span>
),
value: model,
};
});
setModelOptions(options);
} else {
showError(tokensData.t(message));
}
} catch (e) {
showError(e.message || 'Failed to load models');
}
};
function openFluentNotification(key) {
const { t } = latestRef.current;
const SUPPRESS_KEY = 'fluent_notify_suppressed';
if (modelOptions.length === 0) {
// fire-and-forget; a later effect will refresh the notice content
loadModels()
}
if (!key && localStorage.getItem(SUPPRESS_KEY) === '1') return;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
Toast.warning(t('未检测到 FluentRead(流畅阅读),请确认扩展已启用'));
return;
}
setPrefillKey(key || '');
setFluentNoticeOpen(true);
Notification.info({
id: 'fluent-detected',
title: t('检测到 FluentRead(流畅阅读)'),
content: (
<div>
<div style={{ marginBottom: 8 }}>
{key
? t('请选择模型。')
: t('选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
</div>
<div style={{ marginBottom: 8 }}>
<Select
placeholder={t('请选择模型')}
optionList={modelOptions}
onChange={setSelectedModel}
filter={selectFilter}
style={{ width: 320 }}
showClear
searchable
emptyContent={t('暂无数据')}
/>
</div>
<Space>
<Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
{t('一键填充到 FluentRead')}
</Button>
{!key && (
<Button type="warning" onClick={() => {
localStorage.setItem(SUPPRESS_KEY, '1');
Notification.close('fluent-detected');
Toast.info(t('已关闭后续提醒'));
}}>
{t('不再提醒')}
</Button>
)}
<Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
{t('关闭')}
</Button>
</Space>
</div>
),
duration: 0,
});
}
// assign after definition so hook callback can call it safely
openFluentNotificationRef.current = openFluentNotification;
// Prefill to Fluent handler
const handlePrefillToFluent = () => {
const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
const container = document.getElementById('fluent-new-api-container');
if (!container) {
Toast.error(t('未检测到 Fluent 容器'));
return;
}
if (!chosenModel) {
Toast.warning(t('请选择模型'));
return;
}
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (_) { }
}
if (!serverAddress) serverAddress = window.location.origin;
let apiKeyToUse = '';
if (overrideKey) {
apiKeyToUse = 'sk-' + overrideKey;
} else {
const token = (selectedKeys && selectedKeys.length === 1)
? selectedKeys[0]
: (tokens && tokens.length > 0 ? tokens[0] : null);
if (!token) {
Toast.warning(t('没有可用令牌用于填充'));
return;
}
apiKeyToUse = 'sk-' + token.key;
}
const payload = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: apiKeyToUse,
model: chosenModel,
};
container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
Toast.success(t('已发送到 Fluent'));
Notification.close('fluent-detected');
};
// Show notification when Fluent container is available
useEffect(() => {
const onAppeared = () => {
openFluentNotification();
};
const onRemoved = () => {
setFluentNoticeOpen(false);
Notification.close('fluent-detected');
};
window.addEventListener('fluent-container:appeared', onAppeared);
window.addEventListener('fluent-container:removed', onRemoved);
return () => {
window.removeEventListener('fluent-container:appeared', onAppeared);
window.removeEventListener('fluent-container:removed', onRemoved);
};
}, []);
// When modelOptions or language changes while the notice is open, refresh the content
useEffect(() => {
if (fluentNoticeOpen) {
openFluentNotification();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
useEffect(() => {
const selector = '#fluent-new-api-container';
const root = document.body || document.documentElement;
const existing = document.querySelector(selector);
if (existing) {
console.log('Fluent container detected (initial):', existing);
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
}
const isOrContainsTarget = (node) => {
if (!(node && node.nodeType === 1)) return false;
if (node.id === 'fluent-new-api-container') return true;
return typeof node.querySelector === 'function' && !!node.querySelector(selector);
};
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
// appeared
for (const added of m.addedNodes) {
if (isOrContainsTarget(added)) {
const el = document.querySelector(selector);
if (el) {
console.log('Fluent container appeared:', el);
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
}
break;
}
}
// removed
for (const removed of m.removedNodes) {
if (isOrContainsTarget(removed)) {
const elNow = document.querySelector(selector);
if (!elNow) {
console.log('Fluent container removed');
window.dispatchEvent(new CustomEvent('fluent-container:removed'));
}
break;
}
}
}
});
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
const {
// Edit state
@@ -119,6 +357,6 @@ const TokensPage = () => {
</CardPro>
</>
);
};
}
export default TokensPage;
@@ -24,7 +24,8 @@ import {
Tag,
Tooltip,
Progress,
Switch,
Popover,
Typography,
} from '@douyinfe/semi-ui';
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
* Render user statistics
*/
const renderStatistics = (text, record, showEnableDisableModal, t) => {
const enabled = record.status === 1;
const isDeleted = record.DeletedAt !== null;
// Determine tag text & color like original status column
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
tagText = t('已注销');
} else if (record.status === 1) {
tagColor = 'green';
tagText = t('已激活');
tagText = t('已启用');
} else if (record.status === 2) {
tagColor = 'red';
tagText = t('已禁');
tagText = t('已禁');
}
const handleToggle = (checked) => {
if (checked) {
showEnableDisableModal(record, 'enable');
} else {
showEnableDisableModal(record, 'disable');
}
};
const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.quota) || 0;
const total = used + remain;
const percent = total > 0 ? (remain / total) * 100 : 0;
const getProgressColor = (pct) => {
if (pct === 100) return 'var(--semi-color-success)';
if (pct <= 10) return 'var(--semi-color-danger)';
if (pct <= 30) return 'var(--semi-color-warning)';
return undefined;
};
const quotaSuffix = (
<div className='flex flex-col items-end'>
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
<Progress
percent={percent}
stroke={getProgressColor(percent)}
aria-label='quota usage'
format={() => `${percent.toFixed(0)}%`}
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
/>
</div>
);
const content = (
<Tag
color={tagColor}
shape='circle'
size='large'
prefixIcon={
<Switch
size='small'
checked={enabled}
onChange={handleToggle}
disabled={isDeleted}
aria-label='user status switch'
/>
}
suffixIcon={quotaSuffix}
size='small'
>
{tagText}
</Tag>
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
const tooltipContent = (
<div className='text-xs'>
<div>{t('已用额度')}: {renderQuota(used)}</div>
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
<div>{t('总额度')}: {renderQuota(total)}</div>
<div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
</div>
);
@@ -175,6 +129,43 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
);
};
// Render separate quota usage column
const renderQuotaUsage = (text, record, t) => {
const { Paragraph } = Typography;
const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.quota) || 0;
const total = used + remain;
const percent = total > 0 ? (remain / total) * 100 : 0;
const popoverContent = (
<div className='text-xs p-2'>
<Paragraph copyable={{ content: renderQuota(used) }}>
{t('已用额度')}: {renderQuota(used)}
</Paragraph>
<Paragraph copyable={{ content: renderQuota(remain) }}>
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
</Paragraph>
<Paragraph copyable={{ content: renderQuota(total) }}>
{t('总额度')}: {renderQuota(total)}
</Paragraph>
</div>
);
return (
<Popover content={popoverContent} position='top'>
<Tag color='white' shape='circle'>
<div className='flex flex-col items-end'>
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
<Progress
percent={percent}
aria-label='quota usage'
format={() => `${percent.toFixed(0)}%`}
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
/>
</div>
</Tag>
</Popover>
);
};
/**
* Render invite information
*/
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
setShowEditUser,
showPromoteModal,
showDemoteModal,
showEnableDisableModal,
showDeleteModal,
t
}) => {
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
return (
<Space>
{record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => showEnableDisableModal(record, 'disable')}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => showEnableDisableModal(record, 'enable')}
>
{t('启用')}
</Button>
)}
<Button
type='tertiary'
size="small"
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
dataIndex: 'username',
render: (text, record) => renderUsername(text, record),
},
{
title: t('状态'),
dataIndex: 'info',
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
},
{
title: t('剩余额度/总额度'),
key: 'quota_usage',
render: (text, record) => renderQuotaUsage(text, record, t),
},
{
title: t('分组'),
dataIndex: 'group',
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
return <div>{renderRole(text, t)}</div>;
},
},
{
title: t('状态'),
dataIndex: 'info',
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
},
{
title: t('邀请信息'),
dataIndex: 'invite',
+1 -1
View File
@@ -81,7 +81,7 @@ export const CHANNEL_OPTIONS = [
{
value: 16,
color: 'violet',
label: '智谱 ChatGLM',
label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V',
},
{
value: 26,
+16
View File
@@ -49,4 +49,20 @@ function PrivateRoute({ children }) {
return children;
}
export function AdminRoute({ children }) {
const raw = localStorage.getItem('user');
if (!raw) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
try {
const user = JSON.parse(raw);
if (user && typeof user.role === 'number' && user.role >= 10) {
return children;
}
} catch (e) {
// ignore
}
return <Navigate to='/forbidden' replace />;
}
export { PrivateRoute };
+61 -11
View File
@@ -419,7 +419,11 @@ export function getChannelIcon(channelType) {
/**
* 根据图标名称动态获取 LobeHub 图标组件
* @param {string} iconName - 图标名称
* 支持
* - 基础"OpenAI""OpenAI.Color"
* - 额外属性点号链式"OpenAI.Avatar.type={'platform'}""OpenRouter.Avatar.shape={'square'}"
* - 继续兼容第二参数 size若字符串里有 size=以字符串为准
* @param {string} iconName - 图标名称/描述
* @param {number} size - 图标大小默认为 14
* @returns {JSX.Element} - 对应的图标组件或 Avatar
*/
@@ -430,22 +434,68 @@ export function getLobeHubIcon(iconName, size = 14) {
return <Avatar size="extra-extra-small">?</Avatar>;
}
let IconComponent;
// 解析组件路径与点号链式属性
const segments = String(iconName).split('.');
const baseKey = segments[0];
const BaseIcon = LobeIcons[baseKey];
if (iconName.includes('.')) {
const [base, variant] = iconName.split('.');
const BaseIcon = LobeIcons[base];
IconComponent = BaseIcon ? BaseIcon[variant] : undefined;
let IconComponent = undefined;
let propStartIndex = 1;
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
IconComponent = BaseIcon[segments[1]];
propStartIndex = 2;
} else {
IconComponent = LobeIcons[iconName];
IconComponent = LobeIcons[baseKey];
propStartIndex = 1;
}
if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) {
return <IconComponent size={size} />;
// 失败兜底
if (!IconComponent || (typeof IconComponent !== 'function' && typeof IconComponent !== 'object')) {
const firstLetter = String(iconName).charAt(0).toUpperCase();
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
}
const firstLetter = iconName.charAt(0).toUpperCase();
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
// 解析点号链式属性,形如:key={...}、key='...'、key="..."、key=123、key、key=true/false
const props = {};
const parseValue = (raw) => {
if (raw == null) return true;
let v = String(raw).trim();
// 去除一层花括号包裹
if (v.startsWith('{') && v.endsWith('}')) {
v = v.slice(1, -1).trim();
}
// 去除引号
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
return v.slice(1, -1);
}
// 布尔
if (v === 'true') return true;
if (v === 'false') return false;
// 数字
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
// 其他原样返回字符串
return v;
};
for (let i = propStartIndex; i < segments.length; i++) {
const seg = segments[i];
if (!seg) continue;
const eqIdx = seg.indexOf('=');
if (eqIdx === -1) {
props[seg.trim()] = true;
continue;
}
const key = seg.slice(0, eqIdx).trim();
const valRaw = seg.slice(eqIdx + 1).trim();
props[key] = parseValue(valRaw);
}
// 兼容第二参数 size,若字符串中未显式指定 size,则使用函数入参
if (props.size == null && size != null) props.size = size;
return <IconComponent {...props} />;
}
// 颜色列表
+63 -12
View File
@@ -581,13 +581,37 @@ export const calculateModelPrice = ({
tokenUnit,
displayPrice,
currency,
precision = 4
precision = 4,
}) => {
// 1. 选择实际使用的分组
let usedGroup = selectedGroup;
let usedGroupRatio = groupRatio[selectedGroup];
if (selectedGroup === 'all' || usedGroupRatio === undefined) {
// 在模型可用分组中选择倍率最小的分组,若无则使用 1
let minRatio = Number.POSITIVE_INFINITY;
if (Array.isArray(record.enable_groups) && record.enable_groups.length > 0) {
record.enable_groups.forEach((g) => {
const r = groupRatio[g];
if (r !== undefined && r < minRatio) {
minRatio = r;
usedGroup = g;
usedGroupRatio = r;
}
});
}
// 如果找不到合适分组倍率,回退为 1
if (usedGroupRatio === undefined) {
usedGroupRatio = 1;
}
}
// 2. 根据计费类型计算价格
if (record.quota_type === 0) {
// 按量计费
const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
const completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
const completionRatioPriceUSD = record.model_ratio * record.completion_ratio * 2 * usedGroupRatio;
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
@@ -602,22 +626,42 @@ export const calculateModelPrice = ({
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
unitLabel,
isPerToken: true
isPerToken: true,
usedGroup,
usedGroupRatio,
};
} else {
}
if (record.quota_type === 1) {
// 按次计费
const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup];
const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
const displayVal = displayPrice(priceUSD);
return {
price: displayVal,
isPerToken: false
isPerToken: false,
usedGroup,
usedGroupRatio,
};
}
// 未知计费类型,返回占位信息
return {
price: '-',
isPerToken: false,
usedGroup,
usedGroupRatio,
};
};
// 格式化价格信息(用于卡片视图)
export const formatPriceInfo = (priceData, t) => {
const groupTag = priceData.usedGroup ? (
<span style={{ color: 'var(--semi-color-text-1)' }} className="ml-1 text-xs">
{t('分组')} {priceData.usedGroup}
</span>
) : null;
if (priceData.isPerToken) {
return (
<>
@@ -627,15 +671,19 @@ export const formatPriceInfo = (priceData, t) => {
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('补全')} {priceData.completionPrice}/{priceData.unitLabel}
</span>
{groupTag}
</>
);
} else {
return (
}
return (
<>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('模型价格')} {priceData.price}
</span>
);
}
{groupTag}
</>
);
};
// -------------------------------
@@ -699,6 +747,7 @@ const DEFAULT_PRICING_FILTERS = {
filterQuotaType: 'all',
filterEndpointType: 'all',
filterVendor: 'all',
filterTag: 'all',
currentPage: 1,
};
@@ -713,6 +762,7 @@ export const resetPricingFilters = ({
setFilterQuotaType,
setFilterEndpointType,
setFilterVendor,
setFilterTag,
setCurrentPage,
setTokenUnit,
}) => {
@@ -726,5 +776,6 @@ export const resetPricingFilters = ({
setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
};
@@ -31,13 +31,14 @@ export const useModelPricingData = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
const [selectedGroup, setSelectedGroup] = useState('all');
const [showModelDetail, setShowModelDetail] = useState(false);
const [selectedModel, setSelectedModel] = useState(null);
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD');
@@ -88,6 +89,20 @@ export const useModelPricingData = () => {
}
}
// 标签筛选
if (filterTag !== 'all') {
const tagLower = filterTag.toLowerCase();
result = result.filter(model => {
if (!model.tags) return false;
const tagsArr = model.tags
.toLowerCase()
.split(/[,;|\s]+/)
.map(tag => tag.trim())
.filter(Boolean);
return tagsArr.includes(tagLower);
});
}
// 搜索筛选
if (searchValue.length > 0) {
const searchTerm = searchValue.toLowerCase();
@@ -100,7 +115,7 @@ export const useModelPricingData = () => {
}
return result;
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag]);
const rowSelection = useMemo(
() => ({
@@ -165,7 +180,7 @@ export const useModelPricingData = () => {
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default');
setSelectedGroup('all');
// 构建供应商 Map 方便查找
const vendorMap = {};
if (Array.isArray(vendors)) {
@@ -218,12 +233,17 @@ export const useModelPricingData = () => {
setSelectedGroup(group);
// 同时将分组过滤设置为该分组
setFilterGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
if (group === 'all') {
showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
} else {
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group] ?? 1,
}),
);
}
};
const openModelDetail = (model) => {
@@ -233,7 +253,9 @@ export const useModelPricingData = () => {
const closeModelDetail = () => {
setShowModelDetail(false);
setSelectedModel(null);
setTimeout(() => {
setSelectedModel(null);
}, 300);
};
useEffect(() => {
@@ -243,7 +265,7 @@ export const useModelPricingData = () => {
// 当筛选条件变化时重置到第一页
useEffect(() => {
setCurrentPage(1);
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag, searchValue]);
return {
// 状态
@@ -269,6 +291,8 @@ export const useModelPricingData = () => {
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
pageSize,
setPageSize,
currentPage,
@@ -17,115 +17,128 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/*
统一计算模型筛选后的各种集合与动态计数供多个组件复用
*/
import { useMemo } from 'react';
// 工具函数:将 tags 字符串转为小写去重数组
const normalizeTags = (tags = '') =>
tags
.toLowerCase()
.split(/[,;|\s]+/)
.map((t) => t.trim())
.filter(Boolean);
/**
* 统一计算模型筛选后的各种集合与动态计数供多个组件复用
*/
export const usePricingFilterCounts = ({
models = [],
filterGroup = 'all',
filterQuotaType = 'all',
filterEndpointType = 'all',
filterVendor = 'all',
filterTag = 'all',
searchValue = '',
}) => {
// 所有模型(不再需要分类过滤)
// 均使用同一份模型列表,避免创建新引用
const allModels = models;
// 针对计费类型按钮计数
const quotaTypeModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
/**
* 通用过滤函数
* @param {Object} model
* @param {Array<string>} ignore 需要忽略的过滤条件 key
* @returns {boolean}
*/
const matchesFilters = (model, ignore = []) => {
// 分组
if (!ignore.includes('group') && filterGroup !== 'all') {
if (!model.enable_groups || !model.enable_groups.includes(filterGroup)) return false;
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
// 计费类型
if (!ignore.includes('quota') && filterQuotaType !== 'all') {
if (model.quota_type !== filterQuotaType) return false;
}
if (filterVendor !== 'all') {
// 端点类型
if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
if (
!model.supported_endpoint_types ||
!model.supported_endpoint_types.includes(filterEndpointType)
)
return false;
}
// 供应商
if (!ignore.includes('vendor') && filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
if (model.vendor_name) return false;
} else if (model.vendor_name !== filterVendor) {
return false;
}
}
return result;
}, [allModels, filterGroup, filterEndpointType, filterVendor]);
// 针对端点类型按钮计数
const endpointTypeModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
// 标签
if (!ignore.includes('tag') && filterTag !== 'all') {
const tagsArr = normalizeTags(model.tags);
if (!tagsArr.includes(filterTag.toLowerCase())) return false;
}
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
}
}
return result;
}, [allModels, filterGroup, filterQuotaType, filterVendor]);
// === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
const groupCountModels = useMemo(() => {
let result = allModels;
// 不应用 filterGroup 本身
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
}
if (filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
}
}
if (searchValue && searchValue.length > 0) {
// 搜索
if (!ignore.includes('search') && searchValue) {
const term = searchValue.toLowerCase();
result = result.filter(m =>
m.model_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
(m.tags && m.tags.toLowerCase().includes(term)) ||
(m.vendor_name && m.vendor_name.toLowerCase().includes(term))
);
const tags = model.tags ? model.tags.toLowerCase() : '';
if (
!(
model.model_name.toLowerCase().includes(term) ||
(model.description && model.description.toLowerCase().includes(term)) ||
tags.includes(term) ||
(model.vendor_name && model.vendor_name.toLowerCase().includes(term))
)
)
return false;
}
return result;
}, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
// 针对供应商按钮计数
const vendorModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
}
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
}
return result;
}, [allModels, filterGroup, filterQuotaType, filterEndpointType]);
return true;
};
// 生成不同视图所需的模型集合
const quotaTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['quota'])),
[allModels, filterGroup, filterEndpointType, filterVendor, filterTag]
);
const endpointTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
[allModels, filterGroup, filterQuotaType, filterVendor, filterTag]
);
const vendorModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['vendor'])),
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterTag]
);
const tagModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['tag'])),
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterVendor]
);
const groupCountModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['group'])),
[
allModels,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue,
]
);
return {
quotaTypeModels,
endpointTypeModels,
vendorModels,
groupCountModels,
tagModels,
};
};
+5 -1
View File
@@ -29,7 +29,7 @@ import {
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
export const useTokensData = () => {
export const useTokensData = (openFluentNotification) => {
const { t } = useTranslation();
// Basic state
@@ -121,6 +121,10 @@ export const useTokensData = () => {
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
if (url && url.startsWith('fluent')) {
openFluentNotification(record.key);
return;
}
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
+111 -3
View File
@@ -374,6 +374,7 @@
"已用额度": "Quota used",
"剩余额度": "Remaining quota",
"总额度": "Total quota",
"剩余额度/总额度": "Remaining/Total",
"智能熔断": "Smart fallback",
"当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)": "The current group is auto, it will automatically select the optimal group, and automatically downgrade to the next group when a group is unavailable (breakage mechanism)",
"过期时间": "Expiration time",
@@ -584,7 +585,7 @@
"确定是否要修复数据库一致性?": "Are you sure you want to repair database consistency?",
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.",
"当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!",
"令牌管理": "Token Management",
"令牌管理": "API Keys",
"使用日志": "Usage log",
"Midjourney日志": "Midjourney",
"数据看板": "Dashboard",
@@ -1458,6 +1459,7 @@
"设计与开发由": "Designed & Developed with love by",
"演示站点": "Demo Site",
"页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
"您无权访问此页面,请联系管理员": "You do not have permission to access this page. Please contact the administrator.",
"New API项目仓库地址:": "New API project repository address: ",
"© {{currentYear}}": "© {{currentYear}}",
"| 基于": " | Based on ",
@@ -1789,7 +1791,7 @@
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
"自定义模型名称": "Custom model name",
"启用全部密钥": "Enable all keys",
"以充值价格显示": "Show with recharge price",
"以充值价格显示": "Recharge price",
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
"美元汇率": "USD exchange rate",
"隐藏操作项": "Hide actions",
@@ -1810,5 +1812,111 @@
"图片输入: {{imageRatio}}": "Image input: {{imageRatio}}",
"系统提示覆盖": "System prompt override",
"模型: {{ratio}}": "Model: {{ratio}}",
"专属倍率": "Exclusive group ratio"
"专属倍率": "Exclusive group ratio",
"模型管理": "Models",
"匹配类型": "Matching type",
"描述": "Description",
"供应商": "Vendor",
"端点": "Endpoint",
"已绑定渠道": "Bound channels",
"更新时间": "Update time",
"未配置模型": "No model configured",
"预填组管理": "Pre-filled group management",
"搜索供应商": "Search vendor",
"新增供应商": "Add vendor",
"创建新的模型": "Create new model",
"更新模型信息": "Update model information",
"请输入模型名称,如:gpt-4": "Please enter the model name, such as: gpt-4",
"设置模型的基本信息": "Set the basic information of the model",
"名称匹配类型": "Name matching type",
"根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含": "Find model metadata based on model name and matching rules, priority: exact > prefix > suffix > contains",
"请选择名称匹配类型": "Please select the name matching type",
"请输入模型描述": "Please enter the model description",
"输入标签或使用\",\"分隔多个标签": "Enter tags or use \",\" to separate multiple tags",
"选择模型供应商": "Select model vendor",
"端点映射": "Endpoint mapping",
"可视化": "Visualization",
"手动编辑": "Manual editing",
"暂无数据,点击下方按钮添加键值对": "No data, click the button below to add key-value pairs",
"添加键值对": "Add key-value pair",
"留空则使用默认端点;支持 {path, method}": "Leave blank to use the default endpoint; supports {path, method}",
"未配置的模型列表": "Models not configured",
"个未配置模型": "models not configured",
"组列表": "Group list",
"管理模型、标签、端点等预填组": "Manage model, tag, endpoint, etc. pre-filled groups",
"新建组": "New group",
"组名": "Group name",
"项目内容": "Item content",
"创建新的预填组": "Create new pre-filled group",
"更新预填组": "Update pre-filled group",
"设置预填组的基本信息": "Set the basic information of the pre-filled group",
"请输入组名": "Please enter the group name",
"请输入组描述": "Please enter the group description",
"项目": "Item",
"输入项目名称,按回车添加": "Enter the item name, press Enter to add",
"键为端点类型,值为路径和方法对象": "The key is the endpoint type, the value is the path and method object",
"模型组": "Model group",
"标签组": "Tag group",
"端点组": "Endpoint group",
"供应商名称": "Vendor name",
"请输入供应商名称,如:OpenAI": "Please enter the vendor name, such as: OpenAI",
"请输入供应商描述": "Please enter the vendor description",
"供应商图标": "Vendor icon",
"请输入图标名称,如:OpenAI、Claude.Color": "Please enter the icon name, such as: OpenAI, Claude.Color",
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ": "The icon uses the @lobehub/icons library, such as: OpenAI, Claude.Color, supports chain parameters: OpenAI.Avatar.type={'platform'}, OpenRouter.Avatar.shape={'square'}, query all available icons please ",
"请点击我": "Please click me",
"精确": "Exact",
"前缀": "Prefix",
"后缀": "Suffix",
"包含": "Contains",
"全部供应商": "All vendors",
"筛选": "Filter",
"显示设置": "Display settings",
"可用令牌分组": "Available token groups",
"端点类型": "Endpoint type",
"全部分组": "All groups",
"全部类型": "All types",
"全部端点": "All endpoints",
"全部标签": "All tags",
"显示倍率": "Show ratio",
"表格视图": "Table view",
"模型的详细描述和基本特性": "Detailed description and basic characteristics of the model",
"API端点": "API endpoints",
"模型支持的接口端点信息": "Model supported API endpoint information",
"分组价格": "Group price",
"不同用户分组的价格信息": "Price information for different user groups",
"auto分组调用链路": "auto group call chain",
"查看所有可用的AI模型供应商,包括众多知名供应商的模型。": "View all available AI model suppliers, including models from many well-known suppliers.",
"包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。": "Includes AI models from unknown or unmarked suppliers, which may come from small suppliers or open-source projects.",
"该供应商提供多种AI模型,适用于不同的应用场景。": "This supplier provides multiple AI models, suitable for different application scenarios.",
"未知供应商": "Unknown",
"共 {{count}} 个模型": "{{count}} models",
"倍率信息": "Ratio information",
"倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略": "The ratio is used to calculate the final price of different models in the system. If you do not understand the ratio, please ignore it.",
"多密钥管理": "Multi-key management",
"总密钥数": "Total key count",
"随机模式": "Random mode",
"轮询模式": "Polling mode",
"手动禁用": "Manually disabled",
"自动禁用": "Auto disabled",
"暂无密钥数据": "No key data",
"请检查渠道配置或刷新重试": "Please check the channel configuration or refresh and try again",
"全部状态": "All status",
"索引": "Index",
"禁用原因": "Disable reason",
"禁用时间": "Disable time",
"确定要启用所有密钥吗?": "Are you sure you want to enable all keys?",
"确定要禁用所有的密钥吗?": "Are you sure you want to disable all keys?",
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
"删除自动禁用密钥": "Delete auto disabled keys",
"图标": "Icon",
"模型图标": "Model icon",
"请输入图标名称": "Please enter the icon name",
"精确名称匹配": "Exact name matching",
"前缀名称匹配": "Prefix name matching",
"后缀名称匹配": "Suffix name matching",
"包含名称匹配": "Contains name matching",
"展开更多": "Expand more",
"已切换至最优倍率视图,每个模型使用其最低倍率分组": "Switched to the optimal ratio view, each model uses its lowest ratio group"
}
+75 -2
View File
@@ -289,6 +289,27 @@ code {
}
/* ==================== 组件特定样式 ==================== */
/* SelectableButtonGroup */
.sbg-button .semi-button-content {
min-width: 0 !important;
}
.sbg-content {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.sbg-ellipsis {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Tabs组件样式 */
.semi-tabs-content {
padding: 0 !important;
@@ -593,6 +614,60 @@ html:not(.dark) .blur-ball-teal {
opacity: 0.2;
}
/* ==================== 卡片马卡龙模糊球(类封装) ==================== */
/* 使用方式:给容器加上 with-pastel-balls 类即可,无需在 JSX 中插入额外节点 */
.with-pastel-balls {
position: relative;
overflow: hidden;
/* 默认变量(明亮模式) */
--pb1: #ffd1dc;
/* 粉 */
--pb2: #e5d4ff;
/* 薰衣草 */
--pb3: #d1fff6;
/* 薄荷 */
--pb4: #ffe5d9;
/* 桃 */
--pb-opacity: 0.55;
--pb-blur: 60px;
}
.with-pastel-balls::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(circle at -5% -10%, var(--pb1) 0%, transparent 60%),
radial-gradient(circle at 105% -10%, var(--pb2) 0%, transparent 55%),
radial-gradient(circle at 5% 110%, var(--pb3) 0%, transparent 55%),
radial-gradient(circle at 105% 110%, var(--pb4) 0%, transparent 50%);
filter: blur(var(--pb-blur));
opacity: var(--pb-opacity);
transform: translateZ(0);
}
/* 暗黑模式下更柔和的色彩和透明度 */
html.dark .with-pastel-balls {
/* 使用与明亮模式一致的“刚才那组”马卡龙色,但整体更柔和 */
--pb1: #ffd1dc;
/* 粉 */
--pb2: #e5d4ff;
/* 薰衣草 */
--pb3: #d1fff6;
/* 薄荷 */
--pb4: #ffe5d9;
/* 桃 */
--pb-opacity: 0.36;
--pb-blur: 65px;
}
/* 暗黑模式下用更柔和的混合模式避免突兀的高亮 */
html.dark .with-pastel-balls::before {
mix-blend-mode: screen;
}
/* ==================== 表格卡片滚动设置 ==================== */
.table-scroll-card {
display: flex;
@@ -632,7 +707,6 @@ html:not(.dark) .blur-ball-teal {
max-width: 460px;
height: calc(100vh - 60px);
background-color: var(--semi-color-bg-0);
border-right: 1px solid var(--semi-color-border);
overflow: auto;
}
@@ -656,7 +730,6 @@ html:not(.dark) .blur-ball-teal {
.pricing-search-header {
padding: 1rem;
border-bottom: 1px solid var(--semi-color-border);
background-color: var(--semi-color-bg-0);
flex-shrink: 0;
position: sticky;
+40
View File
@@ -0,0 +1,40 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Empty } from '@douyinfe/semi-ui';
import { IllustrationNoAccess, IllustrationNoAccessDark } from '@douyinfe/semi-illustrations';
import { useTranslation } from 'react-i18next';
const Forbidden = () => {
const { t } = useTranslation();
return (
<div className="flex justify-center items-center h-screen p-8">
<Empty
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
darkModeImage={<IllustrationNoAccessDark style={{ width: 250, height: 250 }} />}
description={t('您无权访问此页面,请联系管理员')}
/>
</div>
);
};
export default Forbidden;
+1 -1
View File
@@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next';
const NotFound = () => {
const { t } = useTranslation();
return (
<div className="flex justify-center items-center h-screen p-8 mt-[60px]">
<div className="flex justify-center items-center h-screen p-8">
<Empty
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
darkModeImage={<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />}