Compare commits

...

93 Commits

Author SHA1 Message Date
Calcium-Ion
fa02b5150c Merge pull request #1362 from KamiPasi/patch-1
🔧 refactor(user_usable_group): enhance concurrency control with mutex…
2025-07-18 20:53:29 +08:00
Calcium-Ion
63a1904242 Merge pull request #1393 from feitianbubu/pr/fix-mysql-default-false
fix: mySQL does not support default false
2025-07-18 20:50:28 +08:00
Calcium-Ion
1e3450fdcb Merge pull request #1381 from feitianbubu/pr/task-origin-name
feat: video priority use origin model name
2025-07-18 20:49:53 +08:00
Calcium-Ion
5541026b86 Merge pull request #1190 from feitianbubu/fix-nil-request-id
fix: default request ID to 'SYSTEM' for background tasks
2025-07-18 20:48:15 +08:00
Calcium-Ion
c36c920b34 Merge pull request #1349 from feitianbubu/pr/fix-kling-image-mode-name
fix: KlingText2VideoRequest image and model_name
2025-07-18 20:47:10 +08:00
Calcium-Ion
514fea65c4 Merge pull request #1363 from feitianbubu/pr/add-jimeng-image
feat: 增加即梦生图功能
2025-07-18 20:45:49 +08:00
feitianbubu
e269b3bfdd fix: mySQL does not support default false 2025-07-18 13:52:16 +08:00
feitianbubu
0862a9bfa7 feat: add jimeng image -- fix alpha 2025-07-18 11:47:27 +08:00
t0ng7u
4e2a3d61dc Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-18 09:38:19 +08:00
Xyfacai
b485f2e42e fix(image): 只有 dalle模型才受 size、quality 影响价格 2025-07-18 00:30:49 +08:00
t0ng7u
16e32c3f67 📚 docs: add comprehensive Web-interface API reference (excluding Relay)
A new Markdown file `docs/api/web_api.md` has been added that documents all backend REST endpoints used by the Web UI.

Details:
• Lists every `/api`, `/dashboard`, and `/v1/dashboard` route relevant to the Web front-end
• Excludes every Relay-specific path to keep scope focused on Web operations
• Groups endpoints by functional module (initialisation, public info, user, channel, token, logging, etc.)
• Specifies HTTP method, path, required auth level, and concise description for each entry
• Includes an auth-level legend and update-date placeholder for future maintenance

No application logic was modified; this is documentation-only and improves developer onboarding and API discoverability.
2025-07-17 23:50:56 +08:00
t0ng7u
15f65bb558 🌐 feat: add configurable USD exchange-rate support across backend & frontend
Backend
- setting/payment.go: introduce default `USDExchangeRate` (7.3)
- model/option.go:
  • inject `USDExchangeRate` into `InitOptionMap`
  • persist & sync value in `updateOptionMap`
- controller/misc.go: expose `usd_exchange_rate` via `/api/status`

Frontend
- OperationSetting.js & SettingsGeneral.js:
  • extend state/inputs with `USDExchangeRate`
  • add form field “美元汇率 (non-top-up rate, pricing only)”
- ModelPricing.js already consumes `status.usd_exchange_rate`; no change needed

API
- Administrators can update the rate via `PUT /api/option` (key: `USDExchangeRate`)
- All clients receive the latest rate through `GET /api/status`

This closes the end-to-end flow for displaying model prices in both USD and CNY based on a configurable exchange rate.
2025-07-17 23:04:45 +08:00
CaIon
b161d6831f fix: 修复playground优先级失效 2025-07-17 22:26:38 +08:00
CaIon
969953039f Merge remote-tracking branch 'origin/alpha' into alpha 2025-07-17 22:25:26 +08:00
t0ng7u
f1506ed5da 💸 feat(model-pricing): add “show with recharge price” toggle and USD/CNY selector
* Introduced `showWithRecharge` switch in the actions bar to display model prices based on recharge cost.
* Added a `Select` dropdown (USD / CNY) that appears only when the recharge-price mode is enabled.
* Implemented `displayPrice()` helper to:
  * Convert USD prices to recharge prices using `status.price` and `status.usd_exchange_rate`.
  * Format output according to the selected currency.
* Updated price rendering for both quota types to use the new helper and respect K/M unit conversion.
* Removed the old currency switch from the header, retaining only the K/M unit toggle.
* Extended `SearchAndActions` memo dependencies; imported `Select` from Semi UI.
* Minor refactors and comment clean-up.

No breaking changes.
2025-07-17 22:07:06 +08:00
CaIon
9a239d9e13 refactor: initialize channel cache after channel operations to ensure data consistency 2025-07-17 20:04:26 +08:00
CaIon
a5da09dfb9 feat: 添加手动输入和文件上传模式切换功能,支持密钥搜索和高亮显示 2025-07-17 19:53:33 +08:00
CaIon
6f81f2d143 fix: 修复vertex渠道编辑密钥功能失效 2025-07-17 19:29:28 +08:00
Calcium-Ion
0b877ca8a3 Merge pull request #1383 from feitianbubu/pr/fix-gemini-and-claude-completions
fix: usage cost(any) to support claude and gemini
2025-07-17 19:16:55 +08:00
Calcium-Ion
2911b9cd04 Merge pull request #1368 from RedwindA/fix/embedding-test
fix: 修复Gemini渠道的向量模型测试
2025-07-17 19:16:10 +08:00
Calcium-Ion
6b3f1ab0e4 Merge pull request #1384 from QuantumNous/RequestOpenAI2ClaudeMessage
feat: 改进 RequestOpenAI2ClaudeMessage 和添加 claude web search 计费
2025-07-17 19:15:54 +08:00
Calcium-Ion
2c15655b08 Merge pull request #1387 from feitianbubu/alpha
fix: playground chat
2025-07-17 19:14:34 +08:00
t0ng7u
afa9c650fe 💱 feat(model-pricing): add currency (USD/CNY) & token unit (M/K) toggles to Model Pricing table
* Introduced a currency switch to toggle prices between USD and CNY.
  * CNY prices are calculated by multiplying USD prices with the site-wide `price` rate from `/api/status`.
* Added a second switch to display prices per 1 M tokens or per 1 K tokens.
  * When “K” is selected, prices are divided by 1 000 and labels are updated accordingly.
* Extended component state with `currency` and `tokenUnit` variables.
* Integrated `StatusContext` to retrieve and memoize the current exchange rate.
* Updated price rendering logic and labels to reflect selected currency and token unit.
* Minor UI tweaks: kept Switch components compact and aligned with the table header.

No breaking changes.
2025-07-17 19:07:11 +08:00
IcedTangerine
28d8d82ded Merge pull request #1369 from RedwindA/fix/xai-non-stream
fix xai-non-stream
2025-07-17 17:56:19 +08:00
feitianbubu
a100baf57f fix: playground chat 2025-07-16 23:47:59 +08:00
feitianbubu
d892bfc278 fix: usage cost(any) to support claude and gemini 2025-07-16 16:58:34 +08:00
feitianbubu
4369b18fbf feat: priority use origin model name 2025-07-16 15:38:51 +08:00
同語
3bf0748389 refactor: layout logic to enhance front-end responsiveness
Merge pull request #1377 from QuantumNous/refactor/layout
2025-07-16 04:53:15 +08:00
t0ng7u
cf46b89814 🧹 refactor(PageLayout): remove unused state variables
Removed unused `userState` and `statusState` bindings from `PageLayout.js`
while retaining their dispatch functions for authentication and status
management. Confirmed correct use of array destructuring to skip the
unused toggle function returned by `useSidebarCollapsed`.

This change introduces no functional differences and solely improves
readability and maintainability.
2025-07-16 04:50:23 +08:00
t0ng7u
3360b34af9 🐛 fix: SSR hydration mismatch in mobile detection & clean up sidebar style
- Added a server-snapshot fallback (`() => false`) to `useIsMobile` to ensure
  consistent results between server-side rendering and the browser, preventing
  hydration mismatches.
- Removed a redundant ternary in `PageLayout` sidebar styles, replacing
  `isMobile ? 'fixed' : 'fixed'` with a single `'fixed'` value for clarity.

These changes improve SSR reliability and tidy up inline styles without
affecting runtime functionality.
2025-07-16 04:46:31 +08:00
t0ng7u
4558eb41fc 🎨 feat(sidebar): replace custom collapse button with Semi UI Button
* Replaced the handmade collapse/expand div with Semi UI `<Button>`
  * Uses `theme="outline"` and `type="tertiary"` for outlined style
  * Shows icon + text when sidebar is expanded, icon-only when collapsed
* Added `iconOnly` prop and dynamic padding to remove extra text area in collapsed state
* Ensured full-width layout with consistent padding for both states
* Updated imports to include `Button` from `@douyinfe/semi-ui`
* Maintains tooltip content for accessibility and better UX
2025-07-16 04:35:19 +08:00
t0ng7u
bbc5584f80 ♻️ refactor(auth, ui): simplify Loading component & optimize OAuth2Callback flow
* Removed `prompt` prop from `Loading` and switched to built-in Spin indicator with default size `small`
* Dropped overlay background to make the spinner more reusable
* Replaced custom text span; callers can now supply tip via their own UI if needed
* Cleaned up `OAuth2Callback`:
  - Eliminated unused state/variables
  - Added MAX_RETRIES with incremental back-off
  - Centralized error handling via try/catch
  - Streamlined navigation logic on success/failure
  - Updated imports to match new Loading signature

BREAKING CHANGE: `Loading` no longer accepts a `prompt` prop. Update all invocations accordingly.
2025-07-16 04:21:13 +08:00
t0ng7u
8604c9f9d5 feat(HeaderBar): ensure skeleton shows ≥500 ms and waits for real status data
The header’s skeleton screen now remains visible for at least 500 ms and
only disappears after `/api/status` has successfully populated
`StatusContext`.

Changes include:
• Added `loadingStartRef` to record the mount time.
• Reworked loading effect to compute the remaining delay based on the
  elapsed time and the presence of real status data.
• Removed the previous fixed‐timer logic, preventing premature content
  rendering and improving perceived loading consistency across pages.
2025-07-16 04:02:05 +08:00
t0ng7u
747e02ee0d 📱 feat(ui): auto-close sidebar on mobile after menu navigation
Adds a smoother mobile experience by automatically closing the sidebar
drawer once a menu item is tapped.

### Details
* SiderBar
  * Introduce `onNavigate` prop and invoke it on every `<Link>` click.
  * Remove unused `useIsMobile` hook and related `isMobile` variable.
* PageLayout
  * Pass `onNavigate` callback to `SiderBar` that sets `drawerOpen` to
    `false` when on mobile, ensuring the sidebar collapses after
    navigation.

This eliminates the “isMobile declared but never used” warning and
aligns the behaviour of the sidebar with common mobile UX expectations.
2025-07-16 03:52:40 +08:00
t0ng7u
8b0334309b 💄 style(layout): add horizontal padding to top-offset divs for consistent spacing
Replaced every instance of
<div className="mt-[64px]">
with
<div className="mt-[64px] px-2">
to provide uniform horizontal padding across pages. No functional changes—visual layout improvement only.
2025-07-16 03:42:19 +08:00
t0ng7u
48afa821e4 🖌️ feat(ui): always show colorful console banner for NewAPI in every environment
Add a branded console log to `web/src/index.js` that prints:

“We ❤ NewAPI  Github: https://github.com/QuantumNous/new-api”

Changes include:
• Remove the `NODE_ENV` guard so the banner appears in both development and production.
• Increase font size to 24 px and keep “NewAPI” in bold green for stronger branding.

This is a purely visual/developer-experience enhancement—no runtime behavior is affected.
2025-07-16 03:38:06 +08:00
t0ng7u
42a8d3e3dc 🔧 chore(router): enable React Router v7 future flags to suppress warnings
Add `future` prop to `BrowserRouter` in `web/src/index.js` with
`v7_startTransition` and `v7_relativeSplatPath` turned on.
This opts the app into upcoming React Router v7 behavior, removes
console warnings, and readies the codebase for a smoother future upgrade.

No runtime behavior changes; developer-experience improvement only.
2025-07-16 03:08:04 +08:00
t0ng7u
a44fc51007 📱 refactor(web): remove legacy isMobile util and migrate to useIsMobile hook
BREAKING CHANGE:
helpers/utils.js no longer exports `isMobile()`.
Any external code that relied on this function must switch to the `useIsMobile` React hook.

Summary
-------
1. Deleted the obsolete `isMobile()` function from helpers/utils.js.
2. Introduced `MOBILE_BREAKPOINT` constant and `matchMedia`-based detection for non-React contexts.
3. Reworked toast positioning logic in utils.js to rely on `matchMedia`.
4. Updated render.js:
   • Removed isMobile import.
   • Added MOBILE_BREAKPOINT detection in `truncateText`.
5. Migrated every page/component to the `useIsMobile` hook:
   • Layout: HeaderBar, PageLayout, SiderBar
   • Pages: Home, Detail, Playground, User (Add/Edit), Token, Channel, Redemption, Ratio Sync
   • Components: ChannelsTable, ChannelSelectorModal, ConflictConfirmModal
6. Purged all remaining `isMobile()` calls and legacy imports.
7. Added missing `const isMobile = useIsMobile()` declarations where required.

Benefits
--------
• Unifies mobile detection with a React-friendly hook.
• Eliminates duplicated logic and improves maintainability.
• Keeps non-React helpers lightweight by using `matchMedia` directly.
2025-07-16 02:54:58 +08:00
creamlike1024
961bc874d2 feat: claude web search tool 计费 2025-07-15 18:57:22 +08:00
t0ng7u
b2b018ab93 🚀 feat(frontend): add robust boolean handling across settings pages
Summary
-------
1. Introduced a reusable `toBoolean` utility (`web/src/helpers/boolean.js`) that converts
   strings (`'true'/'false'`, `'1'/'0'`), numbers, and native booleans to a proper boolean.
2. Re-exported `toBoolean` via `web/src/helpers/index.js` for simple one-line imports.

Refactors
---------
• Systematically replaced all legacy `item.value === 'true'` checks with `toBoolean(item.value)` in
  the following components:
  – `SystemSetting.js`
  – `OperationSetting.js`
  – `PaymentSetting.js`
  – `RatioSetting.js`
  – `RateLimitSetting.js`
  – `ModelSetting.js`
  – `DrawingSetting.js`
  – `DashboardSetting.js`
  – `ChatsSetting.js`

• Unified import statements to
  `import { …, toBoolean } from '../../helpers';`
  removing redundant `../../helpers/boolean` paths.

Why
---
SQLite sometimes returns `1/0` or boolean literals instead of the string `'true'/'false'`, causing
checkbox states to reset on page reload. The new utility guarantees consistent boolean parsing,
fixing the issue across all environments (SQLite, MySQL, etc.) while improving code clarity.
2025-07-15 17:18:48 +08:00
creamlike1024
77da33de4f feat: RequestOpenAI2ClaudeMessage add more parms map 2025-07-15 12:38:05 +08:00
t0ng7u
06ad5e3f8c 🐛 fix: multi-key channel sync and Vertex-AI key-upload edge cases
Backend
1. controller/channel.go
   • Always hydrate `ChannelInfo` from DB in `UpdateChannel`, keeping `IsMultiKey` true so `MultiKeySize` is recalculated.

2. model/channel.go
   • getKeys(): accept both newline-separated keys and JSON array (`[ {...}, {...} ]`).
   • Update(): reuse new parser-logic to recalc `MultiKeySize`; prune stale indices in `MultiKeyStatusList`.

Frontend
1. pages/Channel/EditChannel.js
   • `handleVertexUploadChange`
     – Reset `vertexErroredNames` on every change so the “ignored files” prompt always re-appears.
     – In single-key mode keep only the last file; in batch mode keep all valid files.
     – Parse files, display “以下文件解析失败,已忽略:…”.
   • Batch-toggle checkbox
     – When switching from batch→single while multiple files are present, show a confirm dialog and retain only the first file (synchronises state, form and local caches).
   • On opening the “new channel” side-sheet, clear `vertexErroredNames` to restore error prompts.

Result
• “已启用 x/x” count updates immediately after editing multi-key channels.
• Vertex-AI key upload works intuitively: proper error feedback, no duplicated files, and safe down-switch from batch to single mode.
2025-07-15 12:02:04 +08:00
t0ng7u
9326bf96fc style(ui): Change the size of the icons in the dashboard to normal 2025-07-14 23:47:45 +08:00
t0ng7u
bed73102b4 feat: Add fade-in animation for greeting message
- Add greetingVisible state to control animation trigger
- Implement fade-in effect with 1-second smooth transition
- Set 100ms delay before animation starts
- Apply opacity transition from 0 to 1 using ease-in-out timing
- Enhance user experience with smooth visual feedback on page load

The greeting message now appears with an elegant fade-in animation,
transitioning from transparent to fully visible over 1 second,
providing better visual appeal and user engagement.
2025-07-14 23:42:03 +08:00
t0ng7u
eb59f9c75d feat(ui): enhance loading states and fix layout issues
- Fix uptime service card bottom spacing by removing flex layout
- Replace IconRotate with IconSend for request count to better represent semantic meaning
- Add skeleton loading placeholders for all dashboard statistics with 500ms minimum duration
- Unify avgRPM and avgTPM calculation with consistent NaN handling
- Standardize skeleton usage across HeaderBar and Detail components with active animations
- Remove unnecessary empty wrapper elements in skeleton implementations
- Remove gradient styling from system name in header

The changes improve user experience with consistent loading states, better semantic icons,
and eliminate visual layout issues in the dashboard cards.
2025-07-14 23:31:01 +08:00
t0ng7u
f3bd2ed472 💄 fix(ui): improve uptime card layout and request count icon
- Remove flex layout from uptime service card to eliminate bottom spacing
- Remove flex-1 and mt-auto classes that caused unnecessary stretching
- Replace IconRotate with IconSend for request count to better represent the semantic meaning
- Legend now sits directly below content instead of being pushed to bottom

Fixes the visual issue where uptime service availability card had unwanted white space at the bottom when content was minimal.
2025-07-14 22:52:00 +08:00
Xyfacai
456475d593 refactor: format api page query and err result 2025-07-14 22:03:22 +08:00
t0ng7u
a36ce199ba feat: implement backend channel duplication & streamline frontend copy flow
Add a dedicated backend endpoint to clone an existing channel (including its key) and
replace all previous front-end cloning logic with a single API call.

Backend
• controller/channel.go
  – add CopyChannel: safely clone a channel, reset balance/usage, append name suffix,
    preserve key, create abilities, return new ID.
  – supports optional query params: `suffix`, `reset_balance`.
• router/api-router.go
  – register POST /api/channel/copy/:id (secured by AdminAuth).
• model interaction uses BatchInsertChannels to ensure transactional integrity.

Frontend
• ChannelsTable.js
  – simplify copySelectedChannel: call /api/channel/copy/{id} and refresh list.
  – remove complex field-manipulation & key-fetching logic.
  – improved error handling.

Security & stability
• All cloning done server-side; sensitive key never exposed to client.
• Route inherits existing admin middleware.
• Graceful JSON responses with detailed error messages.
2025-07-14 21:54:53 +08:00
RedwindA
b7c3ad0867 🐛 fix: Add nil check for xaiResponse.Usage before calculating CompletionTokens 2025-07-14 21:40:15 +08:00
RedwindA
ea3545cc7e 🐛 fix: Use correct dto for non-stream xai 2025-07-14 21:32:31 +08:00
t0ng7u
232ba46b16 🚀 feat(detail): enhance API Info list with jump button & responsive layout
* Added an “Jump” (`ExternalLink`) tag to each API entry that opens the URL in a new tab
* Placed “Speed Test” and “Jump” tags on the same line as the route
  * Route is left-aligned; tags are right-aligned and wrap to next line when space is insufficient
* Inserted `<Divider />` between API items to improve visual separation
* Tweaked flex gaps and utility classes for consistent spacing and readability
2025-07-14 20:22:09 +08:00
t0ng7u
5f011502d1 🍎style(button): Optimize the colors of all buttons to reduce visual fatigue 2025-07-14 19:49:40 +08:00
RedwindA
93b6f1066b 🎨 feat(channel-test): Enhance request conversion for Embedding models and update input type 2025-07-14 16:59:48 +08:00
t0ng7u
52fe92ed7f 🐛 fix: Ensure channel tooltip displays correctly in LogsTable
* Refactored the render logic of the **Channel** column
  * Wrapped tooltip around the first `Tag` only to restore hover behavior when multi-key mode is enabled
  * Added i18n-friendly fallback (`t('未知渠道')`) for cases where `channel_name` is missing
* Improves user experience for administrators by reliably showing channel names even with multiple keys
2025-07-14 13:12:40 +08:00
t0ng7u
0d005df463 🎨 feat(ui): Change TolenTables Progress Style marginTop 0 to 1px 2025-07-13 19:34:20 +08:00
t0ng7u
e3ef3ace29 🎨 feat(ui): Enhance model dropdowns with icons in Token & Channel editors
Summary
• Added visual model icons to dropdown options in both Token (`EditToken.js`) and Channel (`EditChannel.js`) editors.

Details
1. Token Editor
   - Imported `getModelCategories` from helpers.
   - Re-built Model Limits option list to prepend the matching icon to each model label.

2. Channel Editor
   - Imported `getModelCategories`.
   - Extended model‐option construction to include icons, unifying behaviour with Token editor.
   - Maintained existing logic for merging origin options and user-selected models.

Benefits
• Provides immediate visual identification of model vendors.
• Aligns UX with existing icon usage across the application, improving consistency and clarity.
• No functional changes to data handling; purely UI/UX enhancement.

Co-authored-by: [Your Name]
2025-07-13 19:32:01 +08:00
t0ng7u
a203e98689 💄 feat(ui): Enhance TokensTable quota presentation and clean up code
Summary
• Re-architect the status column to embed quota information directly inside the Tag suffix.
• Consolidate rendering logic for clearer structure and easier maintenance.

Details
• Moved the quota Progress bar under the remaining / total text, inside `quotaSuffix`.
• Added “Unlimited” label for tokens with `unlimited_quota`; hides Progress and Tooltip in this case.
• Tightened vertical spacing with a flex container (`gap-[2px]`, `leading-none`) and removed extra wrappers; Progress now has zero top/bottom margin and full-width style.
• Refactored variables:
  – Replaced `tagNode/bodyContent` with a single `content` node.
  – Wrapped `content` with Tooltip only when quota is limited.
• Visual tweaks:
  – Applied `size='large'` to the Tag for better alignment.
  – Ensured consistent color via shared `getProgressColor`.
• Deleted obsolete comments and redundant code.

Result
Improves readability of the component and delivers a cleaner, more compact quota display.
2025-07-13 19:14:43 +08:00
t0ng7u
27f99a0f38 🔍 feat(edit-token): add search capability to model limit selector
Changes:
• `Form.Select` for “Model Limits” now supports in-dropdown searching (`filter` + `searchPosition='dropdown'`) enabling quick model lookup.
• Removed `maxTagCount` to display all selected models without truncation.

Benefit: simplifies selecting specific models when the list is large, improving usability during token creation/editing.
2025-07-13 18:25:05 +08:00
t0ng7u
d1e48d02bd 🎨 style(tokens-table): enhance quota usage progress bar UI
Improves the “Balance” column in `TokensTable`:

• Re-styled progress bar: wider container (`w-[140px]`) and `showInfo` enabled to display percentage label.
• Added `format={() => \`\${percent}%\`}` for precise percentage feedback.
• Maintains dynamic color mapping (success / warning / danger) for clarity.

Result: clearer, more informative visual representation of remaining token quota.
2025-07-13 18:10:59 +08:00
t0ng7u
4f06a1df50 🔄 fix(tables): keep current page after edits & auto-fallback when page becomes empty
Includes ChannelsTable, RedemptionsTable and UsersTable:

• Refactor `refresh(page = activePage)` in all three tables so data reloads the requested (or current) page instead of forcing page 1.
• On single-row deletion (and bulk deletion in ChannelsTable):
  – Refresh current page immediately.
  – If the refreshed page has no data and `activePage > 1`, automatically load the previous page to avoid blank views.
• RedemptionsTable: corrected prior bug where `refresh` used `activePage - 1`.
• Misc: removed outdated inline comments and aligned search / reset flows.

Result: smoother UX—users stay on their working page, and pagination gracefully adjusts after deletions.
2025-07-13 17:45:31 +08:00
t0ng7u
2d7ae1180f feat(tokens-table): show “Other” avatar for models without vendor logo
Adds UI fallback in TokensTable “Available Models” column:
• Tracks models already matched to a known vendor icon.
• Collects unmatched models and renders a neutral “Other” avatar (labelled via `t('其他')`) with a tooltip listing the model names.
• Removes previous generic fallback so every model is now either vendor-specific or grouped under “Other”.

This improves clarity for users by explicitly indicating models from unrecognized providers rather than leaving them unlabelled.
2025-07-13 17:24:55 +08:00
CaIon
75b486b467 feat: comment out model validation in ConvertClaudeRequest method 2025-07-13 15:17:15 +08:00
CaIon
5b5f10fe93 🔄 update: add bun.lock file copy to Dockerfile for dependency management
- Included the `bun.lock` file in the Dockerfile to ensure consistent dependency installation during the build process.
2025-07-13 14:05:45 +08:00
CaIon
5f654e76e2 🔄 update: downgrade several Babel and Astro dependencies in bun.lock
- Updated `@astrojs/compiler`, `@babel/code-frame`, `@babel/compat-data`, `@babel/core`, `@babel/generator`, `@babel/helper-compilation-targets`, `@babel/helper-define-polyfill-provider`, `@babel/helper-module-imports`, `@babel/helper-module-transforms`, `@babel/plugin-transform-runtime`, `@babel/runtime`, and other related packages to earlier versions to ensure compatibility and stability.
- Adjusted various dependencies within the `bun.lock` file to reflect these changes.
2025-07-13 14:00:12 +08:00
feitianbubu
aa8d112c58 feat: add jimeng image 2025-07-13 13:36:33 +08:00
CaIon
e82dc0e841 feat: add bun.lock file to manage dependencies and remove bun.lockb
- Introduced a new `bun.lock` file to track project dependencies and their versions.
- Deleted the outdated `bun.lockb` file to streamline dependency management.
2025-07-13 12:16:18 +08:00
CaIon
dd741fc38a 🔄 update: regenerate bun.lockb file to reflect dependency changes 2025-07-13 12:08:13 +08:00
KamiPasi
120e4ee92f 🔧 refactor(user_usable_group): enhance concurrency control with mutex for user groups management 2025-07-13 01:03:56 +08:00
t0ng7u
9d2a56bff4 🎨 refactor: TokensTable UI for clearer quota info & compact controls
• Display remaining-quota percentage instead of used-quota in the Progress indicator
  - 100 % when quota is untouched, shown in green
  - Warn at ≤ 30 % (yellow) and at ≤ 10 % (red)
  - Hide internal label (`showInfo={false}`) and move the percentage text into the Tooltip
  - Switch Progress `size` to `small` for a lighter visual footprint

• Update Tooltip to list used, remaining, total quota and the new percentage value

• Uniformly set `size="small"` on all header Buttons and Form inputs within the table
  — enhances readability and keeps the main content centered

UI/UX improvement only; no backend logic affected.
2025-07-13 01:02:55 +08:00
t0ng7u
31d82a3169 🚑 fix: safeguard NewAPIError.Error() against nil pointer panic
Backend
• `types/error.go`
  – Return empty string when receiver itself is `nil`.
  – If `Err` is `nil`, fall back to `errorCode` string to avoid calling `nil.Error()`.

This prevents runtime panics when the error handler builds an OpenAI-style error response but the underlying `Err` field has not been set.
2025-07-13 00:16:38 +08:00
t0ng7u
d22ee5d451 🐛 fix: preserve key data when editing channels & update MultiKeySize correctly
Backend
• `model/channel.go`
  – On multi-key channel updates, recalculate `MultiKeySize` from the current key list.
  – If the request omits `key`, fetch existing keys from DB to avoid resetting the count to `0/0`.
  – Remove out-of-range entries from `MultiKeyStatusList` to keep data consistent.

Frontend
• `web/src/pages/Channel/EditChannel.js`
  – Vertex AI channels no longer require re-uploading a key file in edit mode.
  – When no new key is provided during editing, exclude the `key` field from the payload to prevent overwriting the stored value.

These changes ensure that editing a channel no longer zeroes out the enabled key counter and that Vertex AI channels can be modified without mandatory key re-submission.
2025-07-13 00:09:27 +08:00
t0ng7u
203edaed50 🐛 fix: synchronize MultiKeySize after editing multi-key channels
1. Recalculate `ChannelInfo.MultiKeySize` based on the current key list when a multi-key channel is updated.
2. Purge any indexes in `MultiKeyStatusList` that exceed the new key count to avoid stale or out-of-range data.

This ensures the front-end display (`enabled x/x`) always reflects the real number of keys after users add or remove keys in edit mode.
2025-07-12 23:57:59 +08:00
t0ng7u
93b5638a9c Merge remote-tracking branch 'origin/multi_keys_channel' into alpha
# Conflicts:
#	web/src/components/table/LogsTable.js
#	web/src/i18n/locales/en.json
#	web/src/pages/Channel/EditChannel.js
2025-07-12 23:47:24 +08:00
CaIon
52a5e58f0c feat(adaptor): refactor response handlers to return usage first and improve error handling 2025-07-12 21:12:46 +08:00
CaIon
20607b0b5c feat(logs): add multi-key support in LogsTable and enhance log info generation 2025-07-12 15:14:55 +08:00
Calcium-Ion
6bebfe9e54 Merge pull request #1357 from RedwindA/feat/xAI-search
feat: support xAI-search
2025-07-12 14:28:14 +08:00
CaIon
50b76f4466 feat(channel): improve channel cache handling and add error checks for disabled channels 2025-07-12 14:20:59 +08:00
CaIon
23e4e25e9a feat(channel): implement thread-safe polling 2025-07-12 11:17:08 +08:00
t0ng7u
5b83d478d6 🎨 style(footer): remove redundant bg-semi-color-bg-2 class (#1354)
The background utility class `bg-semi-color-bg-2` was unnecessary after recent
design adjustments and unintentionally overrode intended styles.
Removing it cleans up the markup and allows the footer to inherit the correct
background from its parent containers.

File affected:
- web/src/components/layout/Footer.js

Closes #1354
2025-07-12 03:53:06 +08:00
t0ng7u
dca38d01d6 🐛 fix(tokens-table): show all extra IP addresses in tooltip
Previously, the tooltip that appears when more than one IP address is configured
skipped the second IP (`ips.slice(2)`), so users could not see it unless they
expanded the list in another way.
Changed the slice start index to `1`, ensuring that **every IP after the first
display tag** is included in the tooltip (`ips.slice(1).join(', ')`).

File affected:
- web/src/components/table/TokensTable.js
2025-07-12 03:48:55 +08:00
t0ng7u
0a434d3b3a ⏱️ fix(token): disallow selecting expiration date earlier than now
Add custom validator to the `expired_time` DatePicker in `EditToken.js`
to ensure that selected expiration timestamps are strictly in the future.

- Introduce Promise-based validator that:
  • Rejects invalid date formats with a clear error message
  • Prevents past or current dates and shows “Expiration time cannot be earlier than the current time!”
- Keeps support for the special “never expires” value (-1)
- Blocks form submission until a valid future datetime is provided

This change prevents accidental creation of already expired tokens and
improves overall robustness of token management.
2025-07-12 03:42:05 +08:00
t0ng7u
7c4b83a430 🎨 refactor(TokensTable): refactor TokensTable UI & UX for clearer data and inline actions
This commit overhauls the `TokensTable` component to deliver a cleaner, more intuitive experience.

Key changes
1. Quota
   • Merged “Used” & “Remaining” into a single “Quota” column.
   • Uses a circular `Progress` with %-label; full details shown on tooltip.

2. Status
   • Tag now embeds a small `Switch` (prefixIcon) to enable/disable a token in-place.
   • Removed enable/disable actions from the old dropdown.

3. Columns & layout
   • Added dedicated “Group” column (moved from Status).
   • Added “Key” column:
     – Read-only `Input` styled like Home page base-URL field.
     – Masked value (`sk-abc********xyz`) by default.
     – Eye button toggles reveal/hide; Copy button copies full key (without modal).
   • Dropped “More” menu; Delete is now a direct button in the action area.

4. Model limits
   • Shows vendor icons inside an `AvatarGroup`; tooltip lists the exact models.

5. IP restriction
   • Displays first IP, extra count as “+N” Tag with tooltip.
   • Unlimited shows white Tag.

6. Cleanup / misc.
   • Removed unused helpers (`getQuotaPerUnit`), icons (`IconMore`, eye/copy duplicates, etc.).
   • Replaced legacy modal view of key with inline input behaviour.
   • Tweaked paddings, themes, sizes to align with design system.

BREAKING CHANGE: Table column order & names have changed; update any tests or docs referencing the old structure.
2025-07-12 03:35:19 +08:00
RedwindA
b7f24b428b fix: 调整初始化顺序以避免写入日志失败 2025-07-12 02:23:10 +08:00
RedwindA
22a0ed0ee2 feat: 支持 xAI 网络搜索 2025-07-12 02:22:40 +08:00
t0ng7u
cf711d55a5 🎨 feat(ui): add icon support for Kling (50) & Jimeng (51) channels and iconize type selector
- import Kling & Jimeng icons from @lobehub/icons
- extend getChannelIcon() to return corresponding icons for new channel types 50 (Kling) and 51 (Jimeng)
- enhance EditChannel type selector:
  • introduce useMemo‐based channelOptionList with leading icons
  • utilize getChannelIcon for consistent icon rendering in dropdown options
- minor refactor: add useMemo and getChannelIcon imports in EditChannel.js
2025-07-12 00:02:12 +08:00
t0ng7u
26ea562fdb 🎨 style(ui): move type-specific “Other / Organization” inputs to Basic Info card for smoother channel setup
Summary
• **EditChannel.js**
  – Relocated the following conditional inputs from “Advanced Settings” to the initial “Basic Info” card:
    • Model version (type 18)
    • Deployment region (type 41)
    • Knowledge-base ID (type 21)
    • Account ID (type 39)
    • Agent ID (type 49)
    • OpenAI organization (type 1)
  – No functional changes; layout only.

Why
These fields are commonly filled during the first steps of channel creation. Surfacing them earlier reduces scrolling and streamlines the user workflow.
2025-07-11 23:47:13 +08:00
t0ng7u
efce0c6c57 🌐 i18n: add missing English strings for auto-disable switch in channel settings
Summary
• **en.json**
  – Added translation for “是否自动禁用” → “Whether to automatically disable”.
  – Stubbed/added key for helper text “仅当自动禁用开启时有效,关闭后不会自动禁用该渠道” (translation placeholder left for future update).

Why
These keys were previously untranslated, causing mixed-language UI for the “Auto Disable” toggle in the channel edit form. Filling them (or at least registering the keys) ensures consistent localization and prevents runtime fallbacks.
2025-07-11 23:43:27 +08:00
t0ng7u
a3768dae97 feat: enable fetching model list in creation mode & refine toast-based error handling
Summary
• **EditChannel.js**
  – Displays “Fetch Model List” button for both *create* and *edit* modes (removed `isEdit` guard).
  – Unified model selector placeholder to “Please choose supported models”.
  – Added null-safety checks when parsing Axios responses.
  – Sends requests with `{ skipErrorHandler: true }`, preventing generic *500 Internal Server Error* toasts and relying on context-specific messages instead.

• **helpers/api.js**
  – Introduced `skipErrorHandler` flag in the Axios response interceptor.
    If present, global `showError` is bypassed, giving callers full control over user-facing notifications.
  – Ensures `Promise.reject(error)` is returned for proper error propagation.

Why
Channel creators now enjoy the same convenience as editors when importing upstream model lists.
Meanwhile, suppressing redundant toasts removes confusion caused by simultaneous custom and generic error messages.
2025-07-11 23:37:47 +08:00
CaIon
85efea3fb8 feat(channel): implement multi-key mode handling and improve channel update logic 2025-07-11 21:12:17 +08:00
t0ng7u
c820fda26d 🌐 feat(system-settings): add ServerAddress option & full i18n support
This commit brings System Settings in line with Payment Settings and
prepares the whole component for multi-language deployments.

Key points
----------
• Introduced a “General Settings” card in `SystemSetting.js`
  - Adds a `ServerAddress` field (copied from Payment Settings).
  - Explains that this URL is used for payment callback & default homepage.
  - Implements `submitServerAddress` to persist the option.

• Internationalisation
  - Imported `useTranslation` and wrapped **all** Chinese UI strings in `t()`.
  - Localised section titles, labels, placeholders, banners, buttons,
    modal texts and toast messages.
  - Dynamic banner/tool-tip descriptions now combine `t()` fragments with
    template literals for runtime URLs.

• UX improvements
  - Added contextual `extraText` under the ServerAddress input.
  - Ensured placeholders like “sensitive info…” are translatable.

With these changes, administrators can configure the server URL from the
System Settings tab, and the entire page is ready for seamless language
switching via the existing i18n framework.
2025-07-11 02:53:53 +08:00
CaIon
cd8c23c0ab feat(channel): enhance channel status management 2025-07-10 17:49:53 +08:00
skynono
4196a3db5a fix: KlingText2VideoRequest image and model_name
feat: add video channel kling swag
2025-07-09 07:34:04 +00:00
skynono
b887db474e fix: default request ID to 'SYSTEM' for background tasks 2025-06-09 22:13:21 +08:00
135 changed files with 6103 additions and 2498 deletions

View File

@@ -2,6 +2,7 @@ FROM oven/bun:latest AS builder
WORKDIR /build
COPY web/package.json .
COPY web/bun.lock .
RUN bun install
COPY ./web .
COPY ./VERSION .

View File

@@ -63,6 +63,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeXai
case constant.ChannelTypeCoze:
apiType = constant.APITypeCoze
case constant.ChannelTypeJimeng:
apiType = constant.APITypeJimeng
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/constant"
"strings"
"time"
@@ -86,3 +87,25 @@ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool)
var t T
return t, false
}
func ApiError(c *gin.Context, err error) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
}
func ApiErrorMsg(c *gin.Context, msg string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": msg,
})
}
func ApiSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}

View File

@@ -75,6 +75,9 @@ func logHelper(ctx context.Context, level string, msg string) {
writer = gin.DefaultWriter
}
id := ctx.Value(RequestIdKey)
if id == nil {
id = "SYSTEM"
}
now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
logCount++ // we don't need accurate count, so no lock here

View File

@@ -1,15 +1,14 @@
package common
import (
"github.com/gin-gonic/gin"
"strconv"
"github.com/gin-gonic/gin"
)
type PageInfo struct {
Page int `json:"page"` // page num 页码
PageSize int `json:"page_size"` // page size 页大小
StartTimestamp int64 `json:"start_timestamp"` // 秒级
EndTimestamp int64 `json:"end_timestamp"` // 秒级
Page int `json:"page"` // page num 页码
PageSize int `json:"page_size"` // page size 页大小
Total int `json:"total"` // 总条数,后设置
Items any `json:"items"` // 数据,后设置
@@ -39,11 +38,14 @@ func (p *PageInfo) SetItems(items any) {
p.Items = items
}
func GetPageQuery(c *gin.Context) (*PageInfo, error) {
func GetPageQuery(c *gin.Context) *PageInfo {
pageInfo := &PageInfo{}
err := c.BindQuery(pageInfo)
if err != nil {
return nil, err
// 手动获取并处理每个参数
if page, err := strconv.Atoi(c.Query("page")); err == nil {
pageInfo.Page = page
}
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
pageInfo.PageSize = pageSize
}
if pageInfo.Page < 1 {
// 兼容
@@ -56,7 +58,25 @@ func GetPageQuery(c *gin.Context) (*PageInfo, error) {
}
if pageInfo.PageSize == 0 {
pageInfo.PageSize = ItemsPerPage
// 兼容
pageSize, _ := strconv.Atoi(c.Query("ps"))
if pageSize != 0 {
pageInfo.PageSize = pageSize
}
if pageInfo.PageSize == 0 {
pageSize, _ = strconv.Atoi(c.Query("size")) // token page
if pageSize != 0 {
pageInfo.PageSize = pageSize
}
}
if pageInfo.PageSize == 0 {
pageInfo.PageSize = ItemsPerPage
}
}
return pageInfo, nil
if pageInfo.PageSize > 100 {
pageInfo.PageSize = 100
}
return pageInfo
}

View File

@@ -30,5 +30,6 @@ const (
APITypeXinference
APITypeXai
APITypeCoze
APITypeJimeng
APITypeDummy // this one is only for count, do not add any channel after this
)

View File

@@ -19,7 +19,7 @@ const (
/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
ContextKeyChannelName ContextKey = "channel_name"
ContextKeyChannelCreateTime ContextKey = "channel_create_name"
ContextKeyChannelCreateTime ContextKey = "channel_create_time"
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
@@ -29,6 +29,8 @@ const (
ContextKeyChannelModelMapping ContextKey = "model_mapping"
ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping"
ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key"
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key"
/* user related keys */
ContextKeyUserId ContextKey = "id"

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/shopspring/decimal"
"io"
"net/http"
"one-api/common"
@@ -12,9 +11,12 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"one-api/types"
"strconv"
"time"
"github.com/shopspring/decimal"
"github.com/gin-gonic/gin"
)
@@ -409,26 +411,24 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
func UpdateChannelBalance(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, true)
channel, err := model.CacheGetChannel(id)
if err != nil {
common.ApiError(c, err)
return
}
if channel.ChannelInfo.IsMultiKey {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": "多密钥渠道不支持余额查询",
})
return
}
balance, err := updateChannelBalance(channel)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -436,7 +436,6 @@ func UpdateChannelBalance(c *gin.Context) {
"message": "",
"balance": balance,
})
return
}
func updateAllChannelsBalance() error {
@@ -448,6 +447,9 @@ func updateAllChannelsBalance() error {
if channel.Status != common.ChannelStatusEnabled {
continue
}
if channel.ChannelInfo.IsMultiKey {
continue // skip multi-key channels
}
// TODO: support Azure
//if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom {
// continue
@@ -458,7 +460,7 @@ func updateAllChannelsBalance() error {
} else {
// err is nil & balance <= 0 means quota is used up
if balance <= 0 {
service.DisableChannel(channel.Id, channel.Name, "余额不足")
service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足")
}
}
time.Sleep(common.RequestInterval)
@@ -470,10 +472,7 @@ func UpdateAllChannelsBalance(c *gin.Context) {
// TODO: make it async
err := updateAllChannelsBalance()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -17,6 +17,7 @@ import (
"one-api/model"
"one-api/relay"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/types"
@@ -30,22 +31,43 @@ import (
"github.com/gin-gonic/gin"
)
func testChannel(channel *model.Channel, testModel string) (err error, newAPIError *types.NewAPIError) {
type testResult struct {
context *gin.Context
localErr error
newAPIError *types.NewAPIError
}
func testChannel(channel *model.Channel, testModel string) testResult {
tik := time.Now()
if channel.Type == constant.ChannelTypeMidjourney {
return errors.New("midjourney channel test is not supported"), nil
return testResult{
localErr: errors.New("midjourney channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeMidjourneyPlus {
return errors.New("midjourney plus channel test is not supported"), nil
return testResult{
localErr: errors.New("midjourney plus channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeSunoAPI {
return errors.New("suno channel test is not supported"), nil
return testResult{
localErr: errors.New("suno channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeKling {
return errors.New("kling channel test is not supported"), nil
return testResult{
localErr: errors.New("kling channel test is not supported"),
newAPIError: nil,
}
}
if channel.Type == constant.ChannelTypeJimeng {
return errors.New("jimeng channel test is not supported"), nil
return testResult{
localErr: errors.New("jimeng channel test is not supported"),
newAPIError: nil,
}
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -82,31 +104,49 @@ func testChannel(channel *model.Channel, testModel string) (err error, newAPIErr
cache, err := model.GetUserCache(1)
if err != nil {
return err, nil
return testResult{
localErr: err,
newAPIError: nil,
}
}
cache.WriteContext(c)
c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
c.Request.Header.Set("Content-Type", "application/json")
c.Set("channel", channel.Type)
c.Set("base_url", channel.GetBaseURL())
group, _ := model.GetUserGroup(1, false)
c.Set("group", group)
middleware.SetupContextForSelectedChannel(c, channel, testModel)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
if newAPIError != nil {
return testResult{
context: c,
localErr: newAPIError,
newAPIError: newAPIError,
}
}
info := relaycommon.GenRelayInfo(c)
err = helper.ModelMappedHelper(c, info, nil)
if err != nil {
return err, types.NewError(err, types.ErrorCodeChannelModelMappedError)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
testModel = info.UpstreamModelName
apiType, _ := common.ChannelType2APIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
if adaptor == nil {
return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType)
return testResult{
context: c,
localErr: fmt.Errorf("invalid api type: %d, adaptor is nil", apiType),
newAPIError: types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType),
}
}
request := buildTestRequest(testModel)
@@ -117,45 +157,91 @@ func testChannel(channel *model.Channel, testModel string) (err error, newAPIErr
priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens))
if err != nil {
return err, types.NewError(err, types.ErrorCodeModelPriceError)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
}
}
adaptor.Init(info)
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
var convertedRequest any
// 根据 RelayMode 选择正确的转换函数
if info.RelayMode == relayconstant.RelayModeEmbeddings {
// 创建一个 EmbeddingRequest
embeddingRequest := dto.EmbeddingRequest{
Input: request.Input,
Model: request.Model,
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
}
if err != nil {
return err, types.NewError(err, types.ErrorCodeConvertRequestFailed)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed),
}
}
jsonData, err := json.Marshal(convertedRequest)
if err != nil {
return err, types.NewError(err, types.ErrorCodeJsonMarshalFailed)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed),
}
}
requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(requestBody)
resp, err := adaptor.DoRequest(c, info, requestBody)
if err != nil {
return err, types.NewError(err, types.ErrorCodeDoRequestFailed)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeDoRequestFailed),
}
}
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(httpResp, true)
return err, types.NewError(err, types.ErrorCodeBadResponse)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeBadResponse),
}
}
}
usageA, respErr := adaptor.DoResponse(c, httpResp, info)
if respErr != nil {
return respErr, respErr
return testResult{
context: c,
localErr: respErr,
newAPIError: respErr,
}
}
if usageA == nil {
return errors.New("usage is nil"), types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody)
return testResult{
context: c,
localErr: errors.New("usage is nil"),
newAPIError: types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody),
}
}
usage := usageA.(*dto.Usage)
result := w.Result()
respBody, err := io.ReadAll(result.Body)
if err != nil {
return err, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
return testResult{
context: c,
localErr: err,
newAPIError: types.NewError(err, types.ErrorCodeReadResponseBodyFailed),
}
}
info.PromptTokens = usage.PromptTokens
@@ -188,7 +274,11 @@ func testChannel(channel *model.Channel, testModel string) (err error, newAPIErr
Other: other,
})
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return nil, nil
return testResult{
context: c,
localErr: nil,
newAPIError: nil,
}
}
func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
@@ -203,7 +293,7 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
strings.Contains(model, "bge-") {
testRequest.Model = model
// Embedding 请求
testRequest.Input = []string{"hello world"}
testRequest.Input = []any{"hello world"} // 修改为any因为dto/openai_request.go 的ParseInput方法无法处理[]string类型
return testRequest
}
// 并非Embedding 模型
@@ -231,31 +321,38 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest {
func TestChannel(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(channelId, true)
channel, err := model.CacheGetChannel(channelId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
//defer func() {
// if channel.ChannelInfo.IsMultiKey {
// go func() { _ = channel.SaveChannelInfo() }()
// }
//}()
testModel := c.Query("model")
tik := time.Now()
_, newAPIError := testChannel(channel, testModel)
result := testChannel(channel, testModel)
if result.localErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.localErr.Error(),
"time": 0.0,
})
return
}
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if newAPIError != nil {
if result.newAPIError != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": newAPIError.Error(),
"message": result.newAPIError.Error(),
"time": consumedTime,
})
return
@@ -280,9 +377,9 @@ func testAllChannels(notify bool) error {
}
testAllChannelsRunning = true
testAllChannelsLock.Unlock()
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
return err
channels, getChannelErr := model.GetAllChannels(0, 0, true, false)
if getChannelErr != nil {
return getChannelErr
}
var disableThreshold = int64(common.ChannelDisableThreshold * 1000)
if disableThreshold == 0 {
@@ -299,30 +396,34 @@ func testAllChannels(notify bool) error {
for _, channel := range channels {
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
err, newAPIError := testChannel(channel, "")
result := testChannel(channel, "")
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
shouldBanChannel := false
newAPIError := result.newAPIError
// request error disables the channel
if err != nil {
shouldBanChannel = service.ShouldDisableChannel(channel.Type, newAPIError)
if newAPIError != nil {
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
}
if milliseconds > disableThreshold {
err = errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
shouldBanChannel = true
// 当错误检查通过,才检查响应时间
if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
if milliseconds > disableThreshold {
err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
newAPIError = types.NewError(err, types.ErrorCodeChannelResponseTimeExceeded)
shouldBanChannel = true
}
}
// disable channel
if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
service.DisableChannel(channel.Id, channel.Name, err.Error())
go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
}
// enable channel
if !isChannelEnabled && service.ShouldEnableChannel(err, newAPIError, channel.Status) {
service.EnableChannel(channel.Id, channel.Name)
if !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) {
service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name)
}
channel.UpdateResponseTime(milliseconds)
@@ -339,10 +440,7 @@ func testAllChannels(notify bool) error {
func TestAllChannels(c *gin.Context) {
err := testAllChannels(true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -53,14 +53,7 @@ func parseStatusFilter(statusParam string) int {
}
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
@@ -79,7 +72,7 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
@@ -126,7 +119,7 @@ func GetAllChannels(c *gin.Context) {
order = "id desc"
}
err := baseQuery.Order(order).Limit(pageSize).Offset((p - 1) * pageSize).Omit("key").Find(&channelData).Error
err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
@@ -148,17 +141,12 @@ func GetAllChannels(c *gin.Context) {
for _, r := range results {
typeCounts[r.Type] = r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"total": total,
"page": p,
"page_size": pageSize,
"type_counts": typeCounts,
},
common.ApiSuccess(c, gin.H{
"items": channelData,
"total": total,
"page": pageInfo.GetPage(),
"page_size": pageInfo.GetPageSize(),
"type_counts": typeCounts,
})
return
}
@@ -166,19 +154,13 @@ func GetAllChannels(c *gin.Context) {
func FetchUpstreamModels(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -195,10 +177,7 @@ func FetchUpstreamModels(c *gin.Context) {
}
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -230,10 +209,7 @@ func FetchUpstreamModels(c *gin.Context) {
func FixChannelsAbilities(c *gin.Context) {
success, fails, err := model.FixAbility()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -358,18 +334,12 @@ func SearchChannels(c *gin.Context) {
func GetChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
channel, err := model.GetChannelById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -380,6 +350,46 @@ func GetChannel(c *gin.Context) {
return
}
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
// 如果是添加操作,检查 channel 和 key 是否为空
if isAdd {
if channel == nil || channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}
// 检查模型名称长度是否超过 255
for _, m := range channel.GetModels() {
if len(m) > 255 {
return fmt.Errorf("模型名称过长: %s", m)
}
}
}
// VertexAI 特殊校验
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
return fmt.Errorf("部署地区不能为空")
}
regionMap, err := common.StrToMap(channel.Other)
if err != nil {
return fmt.Errorf("部署地区必须是标准的Json格式例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}")
}
if regionMap["default"] == nil {
return fmt.Errorf("部署地区必须包含default字段")
}
}
return nil
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
@@ -422,6 +432,12 @@ func AddChannel(c *gin.Context) {
addChannelRequest := AddChannelRequest{}
err := c.ShouldBindJSON(&addChannelRequest)
if err != nil {
common.ApiError(c, err)
return
}
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
@@ -429,59 +445,6 @@ func AddChannel(c *gin.Context) {
return
}
err = addChannelRequest.Channel.ValidateSettings()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "channel setting 格式错误:" + err.Error(),
})
return
}
if addChannelRequest.Channel == nil || addChannelRequest.Channel.Key == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "channel cannot be empty",
})
return
}
// Validate the length of the model name
for _, m := range addChannelRequest.Channel.GetModels() {
if len(m) > 255 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("模型名称过长: %s", m),
})
return
}
}
if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
if addChannelRequest.Channel.Other == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区不能为空",
})
return
} else {
regionMap, err := common.StrToMap(addChannelRequest.Channel.Other)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须是标准的Json格式例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}",
})
return
}
if regionMap["default"] == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须包含default字段",
})
return
}
}
}
addChannelRequest.Channel.CreatedTime = common.GetTimestamp()
keys := make([]string, 0)
switch addChannelRequest.Mode {
@@ -497,6 +460,7 @@ func AddChannel(c *gin.Context) {
})
return
}
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
addChannelRequest.Channel.Key = strings.Join(array, "\n")
} else {
cleanKeys := make([]string, 0)
@@ -507,6 +471,7 @@ func AddChannel(c *gin.Context) {
key = strings.TrimSpace(key)
cleanKeys = append(cleanKeys, key)
}
addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys)
addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n")
}
keys = []string{addChannelRequest.Channel.Key}
@@ -545,10 +510,7 @@ func AddChannel(c *gin.Context) {
}
err = model.BatchInsertChannels(channels)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -563,12 +525,10 @@ func DeleteChannel(c *gin.Context) {
channel := model.Channel{Id: id}
err := channel.Delete()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -579,12 +539,10 @@ func DeleteChannel(c *gin.Context) {
func DeleteDisabledChannel(c *gin.Context) {
rows, err := model.DeleteDisabledChannel()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -615,12 +573,10 @@ func DisableTagChannels(c *gin.Context) {
}
err = model.DisableChannelByTag(channelTag.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -640,12 +596,10 @@ func EnableTagChannels(c *gin.Context) {
}
err = model.EnableChannelByTag(channelTag.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -672,12 +626,10 @@ func EditTagChannels(c *gin.Context) {
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -702,12 +654,10 @@ func DeleteChannelBatch(c *gin.Context) {
}
err = model.BatchDeleteChannels(channelBatch.Ids)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -716,9 +666,29 @@ func DeleteChannelBatch(c *gin.Context) {
return
}
type PatchChannel struct {
model.Channel
MultiKeyMode *string `json:"multi_key_mode"`
}
func UpdateChannel(c *gin.Context) {
channel := model.Channel{}
channel := PatchChannel{}
err := c.ShouldBindJSON(&channel)
if err != nil {
common.ApiError(c, err)
return
}
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
originChannel, err := model.GetChannelById(channel.Id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -726,47 +696,20 @@ func UpdateChannel(c *gin.Context) {
})
return
}
err = channel.ValidateSettings()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "channel setting 格式错误:" + err.Error(),
})
return
}
if channel.Type == constant.ChannelTypeVertexAi {
if channel.Other == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区不能为空",
})
return
} else {
regionMap, err := common.StrToMap(channel.Other)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须是标准的Json格式例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}",
})
return
}
if regionMap["default"] == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "部署地区必须包含default字段",
})
return
}
}
// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
channel.ChannelInfo = originChannel.ChannelInfo
// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
}
err = channel.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
channel.Key = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -869,12 +812,10 @@ func BatchSetChannelTag(c *gin.Context) {
}
err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -923,3 +864,53 @@ func GetTagModels(c *gin.Context) {
})
return
}
// CopyChannel handles cloning an existing channel with its key.
// POST /api/channel/copy/:id
// Optional query params:
//
// suffix - string appended to the original name (default "_复制")
// reset_balance - bool, when true will reset balance & used_quota to 0 (default true)
func CopyChannel(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"})
return
}
suffix := c.DefaultQuery("suffix", "_复制")
resetBalance := true
if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" {
if v, err := strconv.ParseBool(rbStr); err == nil {
resetBalance = v
}
}
// fetch original channel with key
origin, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
// clone channel
clone := *origin // shallow copy is sufficient as we will overwrite primitives
clone.Id = 0 // let DB auto-generate
clone.CreatedTime = common.GetTimestamp()
clone.Name = origin.Name + suffix
clone.TestTime = 0
clone.ResponseTime = 0
if resetBalance {
clone.Balance = 0
clone.UsedQuota = 0
}
// insert
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
model.InitChannelCache()
// success
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
}

View File

@@ -5,13 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type GitHubOAuthResponse struct {
@@ -103,10 +104,7 @@ func GitHubOAuth(c *gin.Context) {
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -185,10 +183,7 @@ func GitHubBind(c *gin.Context) {
code := c.Query("code")
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -207,19 +202,13 @@ func GitHubBind(c *gin.Context) {
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.GitHubId = githubUser.Login
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -239,10 +228,7 @@ func GenerateOAuthCode(c *gin.Context) {
session.Set("oauth_state", state)
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -38,10 +38,7 @@ func LinuxDoBind(c *gin.Context) {
code := c.Query("code")
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -63,20 +60,14 @@ func LinuxDoBind(c *gin.Context) {
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.LinuxDOId = strconv.Itoa(linuxdoUser.Id)
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -202,10 +193,7 @@ func LinuxdoOAuth(c *gin.Context) {
code := c.Query("code")
linuxdoUser, err := getLinuxdoUserInfoByCode(code, c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}

View File

@@ -10,14 +10,7 @@ import (
)
func GetAllLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
@@ -26,38 +19,19 @@ func GetAllLogs(c *gin.Context) {
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, (p-1)*pageSize, pageSize, channel, group)
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": map[string]any{
"items": logs,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
func GetUserLogs(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
if pageSize > 100 {
pageSize = 100
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
@@ -65,24 +39,14 @@ func GetUserLogs(c *gin.Context) {
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
group := c.Query("group")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, (p-1)*pageSize, pageSize, group)
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": map[string]any{
"items": logs,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
return
}
@@ -90,10 +54,7 @@ func SearchAllLogs(c *gin.Context) {
keyword := c.Query("keyword")
logs, err := model.SearchAllLogs(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -109,10 +70,7 @@ func SearchUserLogs(c *gin.Context) {
userId := c.GetInt("id")
logs, err := model.SearchUserLogs(userId, keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -198,10 +156,7 @@ func DeleteHistoryLogs(c *gin.Context) {
}
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -13,8 +12,9 @@ import (
"one-api/model"
"one-api/service"
"one-api/setting"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
func UpdateMidjourneyTaskBulk() {
@@ -213,14 +213,7 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
}
func GetAllMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
// 解析其他查询参数
queryParams := model.TaskQueryParams{
@@ -230,7 +223,7 @@ func GetAllMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllTasks((p-1)*pageSize, pageSize, queryParams)
items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllTasks(queryParams)
if setting.MjForwardUrlEnabled {
@@ -239,27 +232,13 @@ func GetAllMidjourney(c *gin.Context) {
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
func GetUserMidjourney(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
@@ -269,7 +248,7 @@ func GetUserMidjourney(c *gin.Context) {
EndTimestamp: c.Query("end_timestamp"),
}
items := model.GetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.CountAllUserTask(userId, queryParams)
if setting.MjForwardUrlEnabled {
@@ -278,14 +257,7 @@ func GetUserMidjourney(c *gin.Context) {
items[i] = midjourney
}
}
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

View File

@@ -77,6 +77,7 @@ func GetStatus(c *gin.Context) {
"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,
@@ -214,10 +215,7 @@ func SendEmailVerification(c *gin.Context) {
"<p>验证码 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, code, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -253,10 +251,7 @@ func SendPasswordResetEmail(c *gin.Context) {
"<p>重置链接 %d 分钟内有效,如果不是本人操作,请忽略。</p>", common.SystemName, link, link, common.VerificationValidMinutes)
err := common.SendEmail(subject, email, content)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -291,10 +286,7 @@ func ResetPassword(c *gin.Context) {
password := common.GenerateVerificationCode(12)
err = model.ResetUserPasswordByEmail(req.Email, password)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
common.DeleteKey(req.Email, common.PasswordResetPurpose)

View File

@@ -126,10 +126,7 @@ func OidcAuth(c *gin.Context) {
code := c.Query("code")
oidcUser, err := getOidcUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -195,10 +192,7 @@ func OidcBind(c *gin.Context) {
code := c.Query("code")
oidcUser, err := getOidcUserInfoByCode(code)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user := model.User{
@@ -217,19 +211,13 @@ func OidcBind(c *gin.Context) {
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.OidcId = oidcUser.OpenID
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -160,10 +160,7 @@ func UpdateOption(c *gin.Context) {
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -58,20 +58,6 @@ func Playground(c *gin.Context) {
}
userId := c.GetInt("id")
//c.Set("token_name", "playground-"+group)
tempToken := &model.Token{
UserId: userId,
Name: fmt.Sprintf("playground-%s", group),
Group: group,
}
_ = middleware.SetupContextForToken(c, tempToken)
_, err = getChannel(c, group, playgroundRequest.Model, 0)
if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
return
}
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
// Write user context to ensure acceptUnsetRatio is available
userCache, err := model.GetUserCache(userId)
@@ -80,5 +66,19 @@ func Playground(c *gin.Context) {
return
}
userCache.WriteContext(c)
tempToken := &model.Token{
UserId: userId,
Name: fmt.Sprintf("playground-%s", group),
Group: group,
}
_ = middleware.SetupContextForToken(c, tempToken)
_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
if newAPIError != nil {
return
}
//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
Relay(c)
}

View File

@@ -1,91 +1,51 @@
package controller
import (
"errors"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"errors"
"github.com/gin-gonic/gin"
)
func GetAllRedemptions(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
redemptions, total, err := model.GetAllRedemptions((p-1)*pageSize, pageSize)
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": redemptions,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func SearchRedemptions(c *gin.Context) {
keyword := c.Query("keyword")
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 0 {
p = 0
}
if pageSize < 1 {
pageSize = common.ItemsPerPage
}
redemptions, total, err := model.SearchRedemptions(keyword, (p-1)*pageSize, pageSize)
pageInfo := common.GetPageQuery(c)
redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": redemptions,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
return
}
func GetRedemption(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
redemption, err := model.GetRedemptionById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -100,10 +60,7 @@ func AddRedemption(c *gin.Context) {
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
@@ -165,10 +122,7 @@ func DeleteRedemption(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
err := model.DeleteRedemptionById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -183,18 +137,12 @@ func UpdateRedemption(c *gin.Context) {
redemption := model.Redemption{}
err := c.ShouldBindJSON(&redemption)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if statusOnly == "" {
@@ -212,10 +160,7 @@ func UpdateRedemption(c *gin.Context) {
}
err = cleanRedemption.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -229,16 +174,13 @@ func UpdateRedemption(c *gin.Context) {
func DeleteInvalidRedemption(c *gin.Context) {
rows, err := model.DeleteInvalidRedemptions()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": rows,
"data": rows,
})
return
}

View File

@@ -80,7 +80,7 @@ func Relay(c *gin.Context) {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
newAPIError = err
break
}
@@ -90,7 +90,7 @@ func Relay(c *gin.Context) {
return // 成功处理请求,直接返回
}
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
@@ -103,10 +103,10 @@ func Relay(c *gin.Context) {
}
if newAPIError != nil {
if newAPIError.StatusCode == http.StatusTooManyRequests {
common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
}
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
@@ -143,7 +143,7 @@ func WssRelay(c *gin.Context) {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
newAPIError = err
break
}
@@ -153,7 +153,7 @@ func WssRelay(c *gin.Context) {
return // 成功处理请求,直接返回
}
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
@@ -166,9 +166,9 @@ func WssRelay(c *gin.Context) {
}
if newAPIError != nil {
if newAPIError.StatusCode == http.StatusTooManyRequests {
newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
}
//if newAPIError.StatusCode == http.StatusTooManyRequests {
// newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
//}
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
helper.WssError(c, ws, newAPIError.ToOpenAIError())
}
@@ -185,7 +185,7 @@ func RelayClaude(c *gin.Context) {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, err.Error())
newAPIError = types.NewError(err, types.ErrorCodeGetChannelFailed)
newAPIError = err
break
}
@@ -195,7 +195,7 @@ func RelayClaude(c *gin.Context) {
return // 成功处理请求,直接返回
}
go processChannelError(c, channel.Id, channel.Type, channel.Name, channel.GetAutoBan(), newAPIError)
go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
break
@@ -243,7 +243,7 @@ func addUsedChannel(c *gin.Context, channelId int) {
c.Set("use_channel", useChannel)
}
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, error) {
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) {
if retryCount == 0 {
autoBan := c.GetBool("auto_ban")
autoBanInt := 1
@@ -260,11 +260,14 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
if group == "auto" {
return nil, errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error()))
return nil, types.NewError(errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error())), types.ErrorCodeGetChannelFailed)
}
return nil, errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error()))
return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed)
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
return nil, newAPIError
}
middleware.SetupContextForSelectedChannel(c, channel, originalModel)
return channel, nil
}
@@ -314,12 +317,12 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return true
}
func processChannelError(c *gin.Context, channelId int, channelType int, channelName string, autoBan bool, err *types.NewAPIError) {
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelId, err.StatusCode, err.Error()))
if service.ShouldDisableChannel(channelType, err) && autoBan {
service.DisableChannel(channelId, channelName, err.Error())
common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
service.DisableChannel(channelError, err.Error())
}
}
@@ -392,10 +395,10 @@ func RelayTask(c *gin.Context) {
retryTimes = 0
}
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
channel, err := getChannel(c, group, originalModel, i)
if err != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", err.Error()))
taskErr = service.TaskErrorWrapperLocal(err, "get_channel_failed", http.StatusInternalServerError)
channel, newAPIError := getChannel(c, group, originalModel, i)
if newAPIError != nil {
common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
channelId = channel.Id
@@ -405,7 +408,7 @@ func RelayTask(c *gin.Context) {
common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
requestBody, err := common.GetRequestBody(c)
requestBody, _ := common.GetRequestBody(c)
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayMode)
}

116
controller/swag_video.go Normal file
View File

@@ -0,0 +1,116 @@
package controller
import (
"github.com/gin-gonic/gin"
)
// VideoGenerations
// @Summary 生成视频
// @Description 调用视频生成接口生成视频
// @Description 支持多种视频生成服务:
// @Description - 可灵AI (Kling): https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo
// @Description - 即梦 (Jimeng): https://www.volcengine.com/docs/85621/1538636
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body dto.VideoRequest true "视频生成请求参数"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /v1/video/generations [post]
func VideoGenerations(c *gin.Context) {
}
// VideoGenerationsTaskId
// @Summary 查询视频
// @Description 根据任务ID查询视频生成任务的状态和结果
// @Tags Video
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param task_id path string true "Task ID"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /v1/video/generations/{task_id} [get]
func VideoGenerationsTaskId(c *gin.Context) {
}
// KlingText2VideoGenerations
// @Summary 可灵文生视频
// @Description 调用可灵AI文生视频接口生成视频内容
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body KlingText2VideoRequest true "视频生成请求参数"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /kling/v1/videos/text2video [post]
func KlingText2VideoGenerations(c *gin.Context) {
}
type KlingText2VideoRequest struct {
ModelName string `json:"model_name,omitempty" example:"kling-v1"`
Prompt string `json:"prompt" binding:"required" example:"A cat playing piano in the garden"`
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
Mode string `json:"mode,omitempty" example:"std"`
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
Duration string `json:"duration,omitempty" example:"5"`
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-001"`
}
type KlingCameraControl struct {
Type string `json:"type,omitempty" example:"simple"`
Config *KlingCameraConfig `json:"config,omitempty"`
}
type KlingCameraConfig struct {
Horizontal float64 `json:"horizontal,omitempty" example:"2.5"`
Vertical float64 `json:"vertical,omitempty" example:"0"`
Pan float64 `json:"pan,omitempty" example:"0"`
Tilt float64 `json:"tilt,omitempty" example:"0"`
Roll float64 `json:"roll,omitempty" example:"0"`
Zoom float64 `json:"zoom,omitempty" example:"0"`
}
// KlingImage2VideoGenerations
// @Summary 可灵官方-图生视频
// @Description 调用可灵AI图生视频接口生成视频内容
// @Tags Video
// @Accept json
// @Produce json
// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)"
// @Param request body KlingImage2VideoRequest true "图生视频请求参数"
// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果"
// @Failure 400 {object} dto.OpenAIError "请求参数错误"
// @Failure 401 {object} dto.OpenAIError "未授权"
// @Failure 403 {object} dto.OpenAIError "无权限"
// @Failure 500 {object} dto.OpenAIError "服务器内部错误"
// @Router /kling/v1/videos/image2video [post]
func KlingImage2VideoGenerations(c *gin.Context) {
}
type KlingImage2VideoRequest struct {
ModelName string `json:"model_name,omitempty" example:"kling-v2-master"`
Image string `json:"image" binding:"required" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"`
Prompt string `json:"prompt,omitempty" example:"A cat playing piano in the garden"`
NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"`
CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"`
Mode string `json:"mode,omitempty" example:"std"`
CameraControl *KlingCameraControl `json:"camera_control,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"`
Duration string `json:"duration,omitempty" example:"5"`
CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"`
ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-002"`
}

View File

@@ -5,8 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"io"
"net/http"
"one-api/common"
@@ -17,6 +15,9 @@ import (
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func UpdateTaskBulk() {
@@ -225,14 +226,7 @@ func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool
}
func GetAllTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
@@ -247,30 +241,15 @@ func GetAllTask(c *gin.Context) {
ChannelID: c.Query("channel_id"),
}
items := model.TaskGetAllTasks((p-1)*pageSize, pageSize, queryParams)
items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllTasks(queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}
func GetUserTask(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 1 {
p = 1
}
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if pageSize <= 0 {
pageSize = common.ItemsPerPage
}
pageInfo := common.GetPageQuery(c)
userId := c.GetInt("id")
@@ -286,17 +265,9 @@ func GetUserTask(c *gin.Context) {
EndTimestamp: endTimestamp,
}
items := model.TaskGetAllUserTask(userId, (p-1)*pageSize, pageSize, queryParams)
items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams)
total := model.TaskCountAllUserTask(userId, queryParams)
c.JSON(200, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": items,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(items)
common.ApiSuccess(c, pageInfo)
}

View File

@@ -1,46 +1,26 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func GetAllTokens(c *gin.Context) {
userId := c.GetInt("id")
p, _ := strconv.Atoi(c.Query("p"))
size, _ := strconv.Atoi(c.Query("size"))
if p < 1 {
p = 1
}
if size <= 0 {
size = common.ItemsPerPage
} else if size > 100 {
size = 100
}
tokens, err := model.GetAllUserTokens(userId, (p-1)*size, size)
pageInfo := common.GetPageQuery(c)
tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// Get total count for pagination
total, _ := model.CountUserTokens(userId)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": tokens,
"total": total,
"page": p,
"page_size": size,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(tokens)
common.ApiSuccess(c, pageInfo)
return
}
@@ -50,10 +30,7 @@ func SearchTokens(c *gin.Context) {
token := c.Query("token")
tokens, err := model.SearchUserTokens(userId, keyword, token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -68,18 +45,12 @@ func GetToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
userId := c.GetInt("id")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
token, err := model.GetTokenByIds(id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -95,10 +66,7 @@ func GetTokenStatus(c *gin.Context) {
userId := c.GetInt("id")
token, err := model.GetTokenByIds(tokenId, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
expiredAt := token.ExpiredTime
@@ -118,10 +86,7 @@ func AddToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(token.Name) > 30 {
@@ -156,10 +121,7 @@ func AddToken(c *gin.Context) {
}
err = cleanToken.Insert()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -174,10 +136,7 @@ func DeleteToken(c *gin.Context) {
userId := c.GetInt("id")
err := model.DeleteTokenById(id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -193,10 +152,7 @@ func UpdateToken(c *gin.Context) {
token := model.Token{}
err := c.ShouldBindJSON(&token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if len(token.Name) > 30 {
@@ -208,10 +164,7 @@ func UpdateToken(c *gin.Context) {
}
cleanToken, err := model.GetTokenByIds(token.Id, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if token.Status == common.TokenStatusEnabled {
@@ -245,10 +198,7 @@ func UpdateToken(c *gin.Context) {
}
err = cleanToken.Update()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -275,10 +225,7 @@ func DeleteTokenBatch(c *gin.Context) {
userId := c.GetInt("id")
count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -1,10 +1,12 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func GetAllQuotaDates(c *gin.Context) {
@@ -13,10 +15,7 @@ func GetAllQuotaDates(c *gin.Context) {
username := c.Query("username")
dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -41,10 +40,7 @@ func GetUserQuotaDates(c *gin.Context) {
}
dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -188,10 +188,7 @@ func Register(c *gin.Context) {
cleanUser.Email = user.Email
}
if err := cleanUser.Insert(inviterId); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -247,81 +244,45 @@ func Register(c *gin.Context) {
}
func GetAllUsers(c *gin.Context) {
pageInfo, err := common.GetPageQuery(c)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "parse page query failed",
})
return
}
pageInfo := common.GetPageQuery(c)
users, total, err := model.GetAllUsers(pageInfo)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": pageInfo,
})
common.ApiSuccess(c, pageInfo)
return
}
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
if p < 1 {
p = 1
}
if pageSize < 0 {
pageSize = common.ItemsPerPage
}
startIdx := (p - 1) * pageSize
users, total, err := model.SearchUsers(keyword, group, startIdx, pageSize)
pageInfo := common.GetPageQuery(c)
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": users,
"total": total,
"page": p,
"page_size": pageSize,
},
})
pageInfo.SetTotal(int(total))
pageInfo.SetItems(users)
common.ApiSuccess(c, pageInfo)
return
}
func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -344,10 +305,7 @@ func GenerateAccessToken(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// get rand int 28-32
@@ -372,10 +330,7 @@ func GenerateAccessToken(c *gin.Context) {
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -395,18 +350,12 @@ func TransferAffQuota(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
tran := TransferAffQuotaRequest{}
if err := c.ShouldBindJSON(&tran); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
err = user.TransferAffQuotaToQuota(tran.Quota)
@@ -427,10 +376,7 @@ func GetAffCode(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if user.AffCode == "" {
@@ -455,10 +401,7 @@ func GetSelf(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
@@ -479,10 +422,7 @@ func GetUserModels(c *gin.Context) {
}
user, err := model.GetUserCache(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
groups := setting.GetUserUsableGroups(user.Group)
@@ -524,10 +464,7 @@ func UpdateUser(c *gin.Context) {
}
originUser, err := model.GetUserById(updatedUser.Id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -550,10 +487,7 @@ func UpdateUser(c *gin.Context) {
}
updatePassword := updatedUser.Password != ""
if err := updatedUser.Edit(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if originUser.Quota != updatedUser.Quota {
@@ -599,17 +533,11 @@ func UpdateSelf(c *gin.Context) {
}
updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -640,18 +568,12 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int
func DeleteUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
originUser, err := model.GetUserById(id, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
myRole := c.GetInt("role")
@@ -686,10 +608,7 @@ func DeleteSelf(c *gin.Context) {
err := model.DeleteUserById(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -735,10 +654,7 @@ func CreateUser(c *gin.Context) {
DisplayName: user.DisplayName,
}
if err := cleanUser.Insert(0); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
@@ -848,10 +764,7 @@ func ManageUser(c *gin.Context) {
}
if err := user.Update(false); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
clearUser := model.User{
@@ -883,20 +796,14 @@ func EmailBind(c *gin.Context) {
}
err := user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.Email = email
// no need to check if this email already taken, because we have used verification code to check it
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -918,19 +825,13 @@ func TopUp(c *gin.Context) {
req := topUpRequest{}
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
id := c.GetInt("id")
quota, err := model.Redeem(req.Key, id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1013,10 +914,7 @@ func UpdateUserSetting(c *gin.Context) {
userId := c.GetInt("id")
user, err := model.GetUserById(userId, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}

View File

@@ -4,13 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type wechatLoginResponse struct {
@@ -150,19 +151,13 @@ func WeChatBind(c *gin.Context) {
}
err = user.FillUserById()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
user.WeChatId = wechatId
err = user.Update(false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{

View File

195
docs/api/web_api.md Normal file
View File

@@ -0,0 +1,195 @@
# One API Web 界面后端接口文档
> 本文档汇总了 **One API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。
>
> 接口前缀统一为 `https://<your-domain>`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。
>
> 鉴权级别说明:
> * **公开** 不需要登录即可调用
> * **用户** 需携带用户 Token`middleware.UserAuth`
> * **管理员** 需管理员 Token`middleware.AdminAuth`
> * **Root** 仅限最高权限 Root 用户(`middleware.RootAuth`
---
## 1. 初始化 / 系统状态
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/setup | 公开 | 获取系统初始化状态 |
| POST | /api/setup | 公开 | 完成首次安装向导 |
| GET | /api/status | 公开 | 获取运行状态摘要 |
| GET | /api/uptime/status | 公开 | Uptime-Kuma 兼容状态探针 |
| GET | /api/status/test | 管理员 | 测试后端与依赖组件是否正常 |
## 2. 公共信息
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/models | 用户 | 获取前端可用模型列表 |
| GET | /api/notice | 公开 | 获取公告栏内容 |
| GET | /api/about | 公开 | 关于页面信息 |
| GET | /api/home_page_content | 公开 | 首页自定义内容 |
| GET | /api/pricing | 可匿名/用户 | 价格与套餐信息 |
| GET | /api/ratio_config | 公开 | 模型倍率配置(仅公开字段) |
## 3. 邮件 / 身份验证
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/verification | 公开 (限流) | 发送邮箱验证邮件 |
| GET | /api/reset_password | 公开 (限流) | 发送重置密码邮件 |
| POST | /api/user/reset | 公开 | 提交重置密码请求 |
## 4. OAuth / 第三方登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |
| GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 |
| GET | /api/oauth/email/bind | 公开 | 邮箱绑定 |
| GET | /api/oauth/telegram/login | 公开 | Telegram 登录 |
| GET | /api/oauth/telegram/bind | 公开 | Telegram 账户绑定 |
| GET | /api/oauth/state | 公开 | 获取随机 state防 CSRF |
## 5. 用户模块
### 5.1 账号注册/登录
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | /api/user/register | 公开 | 注册新账号 |
| POST | /api/user/login | 公开 | 用户登录 |
| GET | /api/user/logout | 用户 | 退出登录 |
| GET | /api/user/epay/notify | 公开 | Epay 支付回调 |
| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) |
### 5.2 用户自身操作 (需登录)
| GET | /api/user/self/groups | 用户 | 获取自己所在分组 |
| GET | /api/user/self | 用户 | 获取个人资料 |
| GET | /api/user/models | 用户 | 获取模型可见性 |
| PUT | /api/user/self | 用户 | 修改个人资料 |
| DELETE | /api/user/self | 用户 | 注销账号 |
| GET | /api/user/token | 用户 | 生成用户级别 Access Token |
| GET | /api/user/aff | 用户 | 获取推广码信息 |
| POST | /api/user/topup | 用户 | 余额直充 |
| POST | /api/user/pay | 用户 | 提交支付订单 |
| POST | /api/user/amount | 用户 | 余额支付 |
| POST | /api/user/aff_transfer | 用户 | 推广额度转账 |
| PUT | /api/user/setting | 用户 | 更新用户设置 |
### 5.3 管理员用户管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/user/ | 管理员 | 获取全部用户列表 |
| GET | /api/user/search | 管理员 | 搜索用户 |
| GET | /api/user/:id | 管理员 | 获取单个用户信息 |
| POST | /api/user/ | 管理员 | 创建用户 |
| POST | /api/user/manage | 管理员 | 冻结/重置等管理操作 |
| PUT | /api/user/ | 管理员 | 更新用户 |
| DELETE | /api/user/:id | 管理员 | 删除用户 |
## 6. 站点选项 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/option/ | Root | 获取全局配置 |
| PUT | /api/option/ | Root | 更新全局配置 |
| POST | /api/option/rest_model_ratio | Root | 重置模型倍率 |
| POST | /api/option/migrate_console_setting | Root | 迁移旧版控制台配置 |
## 7. 模型倍率同步 (Root)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/ratio_sync/channels | Root | 获取可同步渠道列表 |
| POST | /api/ratio_sync/fetch | Root | 从上游拉取倍率 |
## 8. 渠道管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/channel/ | 获取渠道列表 |
| GET | /api/channel/search | 搜索渠道 |
| GET | /api/channel/models | 查询渠道模型能力 |
| GET | /api/channel/models_enabled | 查询启用模型能力 |
| GET | /api/channel/:id | 获取单个渠道 |
| GET | /api/channel/test | 批量测试渠道连通性 |
| GET | /api/channel/test/:id | 单个渠道测试 |
| GET | /api/channel/update_balance | 批量刷新余额 |
| GET | /api/channel/update_balance/:id | 单个刷新余额 |
| POST | /api/channel/ | 新增渠道 |
| PUT | /api/channel/ | 更新渠道 |
| DELETE | /api/channel/disabled | 删除已禁用渠道 |
| POST | /api/channel/tag/disabled | 批量禁用标签渠道 |
| POST | /api/channel/tag/enabled | 批量启用标签渠道 |
| PUT | /api/channel/tag | 编辑渠道标签 |
| DELETE | /api/channel/:id | 删除渠道 |
| POST | /api/channel/batch | 批量删除渠道 |
| POST | /api/channel/fix | 修复渠道能力表 |
| GET | /api/channel/fetch_models/:id | 拉取单渠道模型 |
| POST | /api/channel/fetch_models | 拉取全部渠道模型 |
| POST | /api/channel/batch/tag | 批量设置渠道标签 |
| GET | /api/channel/tag/models | 根据标签获取模型 |
| POST | /api/channel/copy/:id | 复制渠道 |
## 9. Token 管理
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/token/ | 用户 | 获取全部 Token |
| GET | /api/token/search | 用户 | 搜索 Token |
| GET | /api/token/:id | 用户 | 获取单个 Token |
| POST | /api/token/ | 用户 | 创建 Token |
| PUT | /api/token/ | 用户 | 更新 Token |
| DELETE | /api/token/:id | 用户 | 删除 Token |
| POST | /api/token/batch | 用户 | 批量删除 Token |
## 10. 兑换码管理 (管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/redemption/ | 获取兑换码列表 |
| GET | /api/redemption/search | 搜索兑换码 |
| GET | /api/redemption/:id | 获取单个兑换码 |
| POST | /api/redemption/ | 创建兑换码 |
| PUT | /api/redemption/ | 更新兑换码 |
| DELETE | /api/redemption/invalid | 删除无效兑换码 |
| DELETE | /api/redemption/:id | 删除兑换码 |
## 11. 日志
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/log/ | 管理员 | 获取全部日志 |
| DELETE | /api/log/ | 管理员 | 删除历史日志 |
| GET | /api/log/stat | 管理员 | 日志统计 |
| GET | /api/log/self/stat | 用户 | 我的日志统计 |
| GET | /api/log/search | 管理员 | 搜索全部日志 |
| GET | /api/log/self | 用户 | 获取我的日志 |
| GET | /api/log/self/search | 用户 | 搜索我的日志 |
| GET | /api/log/token | 公开 | 根据 Token 查询日志(支持 CORS |
## 12. 数据统计
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/data/ | 管理员 | 全站用量按日期统计 |
| GET | /api/data/self | 用户 | 我的用量按日期统计 |
## 13. 分组
| GET | /api/group/ | 管理员 | 获取全部分组列表 |
## 14. Midjourney 任务
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/mj/self | 用户 | 获取自己的 MJ 任务 |
| GET | /api/mj/ | 管理员 | 获取全部 MJ 任务 |
## 15. 任务中心
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/task/self | 用户 | 获取我的任务 |
| GET | /api/task/ | 管理员 | 获取全部任务 |
## 16. 账户计费面板 (Dashboard)
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /dashboard/billing/subscription | 用户 Token | 获取订阅额度信息 |
| GET | /v1/dashboard/billing/subscription | 同上 | 兼容 OpenAI SDK 路径 |
| GET | /dashboard/billing/usage | 用户 Token | 获取使用量信息 |
| GET | /v1/dashboard/billing/usage | 同上 | 兼容 OpenAI SDK 路径 |
---
> **更新日期**2025.07.17

View File

@@ -159,6 +159,27 @@ type InputSchema struct {
Required any `json:"required,omitempty"`
}
type ClaudeWebSearchTool struct {
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
Type string `json:"type"`
Timezone string `json:"timezone,omitempty"`
Country string `json:"country,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
}
type ClaudeToolChoice struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"`
}
type ClaudeRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
@@ -177,6 +198,59 @@ type ClaudeRequest struct {
Thinking *Thinking `json:"thinking,omitempty"`
}
// AddTool 添加工具到请求中
func (c *ClaudeRequest) AddTool(tool any) {
if c.Tools == nil {
c.Tools = make([]any, 0)
}
switch tools := c.Tools.(type) {
case []any:
c.Tools = append(tools, tool)
default:
// 如果Tools不是[]any类型重新初始化为[]any
c.Tools = []any{tool}
}
}
// GetTools 获取工具列表
func (c *ClaudeRequest) GetTools() []any {
if c.Tools == nil {
return nil
}
switch tools := c.Tools.(type) {
case []any:
return tools
default:
return nil
}
}
// ProcessTools 处理工具列表,支持类型断言
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
var normalTools []*Tool
var webSearchTools []*ClaudeWebSearchTool
for _, tool := range tools {
switch t := tool.(type) {
case *Tool:
normalTools = append(normalTools, t)
case *ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, t)
case Tool:
normalTools = append(normalTools, &t)
case ClaudeWebSearchTool:
webSearchTools = append(webSearchTools, &t)
default:
// 未知类型,跳过
continue
}
}
return normalTools, webSearchTools
}
type Thinking struct {
Type string `json:"type"`
BudgetTokens *int `json:"budget_tokens,omitempty"`
@@ -251,8 +325,13 @@ func (c *ClaudeResponse) GetIndex() int {
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use"`
}
type ClaudeServerToolUse struct {
WebSearchRequests int `json:"web_search_requests"`
}

View File

@@ -55,6 +55,7 @@ type GeneralOpenAIRequest struct {
EnableThinking any `json:"enable_thinking,omitempty"` // ali
THINKING json.RawMessage `json:"thinking,omitempty"` // doubao
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
SearchParameters any `json:"search_parameters,omitempty"` //xai
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`

View File

@@ -182,7 +182,7 @@ type Usage struct {
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// OpenRouter Params
Cost float64 `json:"cost,omitempty"`
Cost any `json:"cost,omitempty"`
}
type InputTokenDetails struct {

View File

@@ -168,11 +168,11 @@ func InitResources() error {
common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.")
}
common.SetupLogger()
// 加载环境变量
common.InitEnv()
common.SetupLogger()
// Initialize model settings
ratio_setting.InitRatioSettings()

View File

@@ -123,6 +123,18 @@ func authHelper(c *gin.Context, minRole int) {
c.Set("id", id)
c.Set("group", session.Get("group"))
c.Set("use_access_token", useAccessToken)
//userCache, err := model.GetUserCache(id.(int))
//if err != nil {
// c.JSON(http.StatusOK, gin.H{
// "success": false,
// "message": err.Error(),
// })
// c.Abort()
// return
//}
//userCache.WriteContext(c)
c.Next()
}

View File

@@ -12,6 +12,7 @@ import (
"one-api/service"
"one-api/setting"
"one-api/setting/ratio_setting"
"one-api/types"
"strconv"
"strings"
"time"
@@ -249,10 +250,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return &modelRequest, shouldSelectChannel, nil
}
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) {
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
c.Set("original_model", modelName) // for retry
if channel == nil {
return
return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed)
}
common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)
@@ -266,11 +267,17 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common.SetContextKey(c, constant.ContextKeyChannelAutoBan, channel.GetAutoBan())
common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping())
common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping())
key, index, newAPIError := channel.GetNextEnabledKey()
if newAPIError != nil {
return newAPIError
}
if channel.ChannelInfo.IsMultiKey {
common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true)
common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index)
}
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
// c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
common.SetContextKey(c, constant.ContextKeyChannelKey, key)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
// TODO: api_version统一
@@ -292,6 +299,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
case constant.ChannelTypeCoze:
c.Set("bot_id", channel.Other)
}
return nil
}
// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名

View File

@@ -18,7 +18,7 @@ func KlingRequestConvert() func(c *gin.Context) {
return
}
model, _ := originalReq["model"].(string)
model, _ := originalReq["model_name"].(string)
prompt, _ := originalReq["prompt"].(string)
unifiedReq := map[string]interface{}{
@@ -36,7 +36,7 @@ func KlingRequestConvert() func(c *gin.Context) {
// Rewrite request body and path
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
c.Request.URL.Path = "/v1/video/generations"
if image := originalReq["image"]; image == "" {
if image, ok := originalReq["image"]; !ok || image == "" {
c.Set("action", constant.TaskActionTextGenerate)
}

View File

@@ -87,26 +87,29 @@ func getPriority(group string, model string, retry int) (int, error) {
return priorityToUse, nil
}
func getChannelQuery(group string, model string, retry int) *gorm.DB {
func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true)
channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery)
if retry != 0 {
priority, err := getPriority(group, model, retry)
if err != nil {
common.SysError(fmt.Sprintf("Get priority failed: %s", err.Error()))
return nil, err
} else {
channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority)
}
}
return channelQuery
return channelQuery, nil
}
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
var abilities []Ability
var err error = nil
channelQuery := getChannelQuery(group, model, retry)
channelQuery, err := getChannelQuery(group, model, retry)
if err != nil {
return nil, err
}
if common.UsingSQLite || common.UsingPostgreSQL {
err = channelQuery.Order("weight DESC").Find(&abilities).Error
} else {

View File

@@ -3,11 +3,13 @@ package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"math/rand"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/types"
"strings"
"sync"
@@ -48,6 +50,7 @@ type Channel struct {
type ChannelInfo struct {
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表key index -> status
MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
@@ -68,22 +71,34 @@ func (channel *Channel) getKeys() []string {
if channel.Key == "" {
return []string{}
}
// use \n to split keys
trimmed := strings.TrimSpace(channel.Key)
// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
if strings.HasPrefix(trimmed, "[") {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
res := make([]string, len(arr))
for i, v := range arr {
res[i] = string(v)
}
return res
}
}
// Otherwise, fall back to splitting by newline
keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n")
return keys
}
func (channel *Channel) GetNextEnabledKey() (string, error) {
func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
// If not in multi-key mode, return the original key string directly.
if !channel.ChannelInfo.IsMultiKey {
return channel.Key, nil
return channel.Key, 0, nil
}
// Obtain all keys (split by \n)
keys := channel.getKeys()
if len(keys) == 0 {
// No keys available, return error, should disable the channel
return "", fmt.Errorf("no valid keys in channel")
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
}
statusList := channel.ChannelInfo.MultiKeyStatusList
@@ -107,16 +122,37 @@ func (channel *Channel) GetNextEnabledKey() (string, error) {
}
// If no specific status list or none enabled, fall back to first key
if len(enabledIdx) == 0 {
return keys[0], nil
return keys[0], 0, nil
}
switch channel.ChannelInfo.MultiKeyMode {
case constant.MultiKeyModeRandom:
// Randomly pick one enabled key
return keys[enabledIdx[rand.Intn(len(enabledIdx))]], nil
selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))]
return keys[selectedIdx], selectedIdx, nil
case constant.MultiKeyModePolling:
// Use channel-specific lock to ensure thread-safe polling
lock := getChannelPollingLock(channel.Id)
lock.Lock()
defer lock.Unlock()
channelInfo, err := CacheGetChannelInfo(channel.Id)
if err != nil {
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed)
}
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
defer func() {
if common.DebugEnabled {
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
}
if !common.MemoryCacheEnabled {
_ = channel.SaveChannelInfo()
} else {
// CacheUpdateChannel(channel)
}
}()
// Start from the saved polling index and look for the next enabled key
start := channel.ChannelInfo.MultiKeyPollingIndex
start := channelInfo.MultiKeyPollingIndex
if start < 0 || start >= len(keys) {
start = 0
}
@@ -125,17 +161,21 @@ func (channel *Channel) GetNextEnabledKey() (string, error) {
if getStatus(idx) == common.ChannelStatusEnabled {
// update polling index for next call (point to the next position)
channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys)
return keys[idx], nil
return keys[idx], idx, nil
}
}
// Fallback should not happen, but return first enabled key
return keys[enabledIdx[0]], nil
return keys[enabledIdx[0]], enabledIdx[0], nil
default:
// Unknown mode, default to first enabled key (or original key string)
return keys[enabledIdx[0]], nil
return keys[enabledIdx[0]], enabledIdx[0], nil
}
}
func (channel *Channel) SaveChannelInfo() error {
return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error
}
func (channel *Channel) GetModels() []string {
if channel.Models == "" {
return []string{}
@@ -271,14 +311,20 @@ func SearchChannels(keyword string, group string, model string, idSort bool) ([]
}
func GetChannelById(id int, selectAll bool) (*Channel, error) {
channel := Channel{Id: id}
channel := &Channel{Id: id}
var err error = nil
if selectAll {
err = DB.First(&channel, "id = ?", id).Error
err = DB.First(channel, "id = ?", id).Error
} else {
err = DB.Omit("key").First(&channel, "id = ?", id).Error
err = DB.Omit("key").First(channel, "id = ?", id).Error
}
return &channel, err
if err != nil {
return nil, err
}
if channel == nil {
return nil, errors.New("channel not found")
}
return channel, nil
}
func BatchInsertChannels(channels []Channel) error {
@@ -362,6 +408,44 @@ func (channel *Channel) Insert() error {
}
func (channel *Channel) Update() error {
// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
if channel.ChannelInfo.IsMultiKey {
var keyStr string
if channel.Key != "" {
keyStr = channel.Key
} else {
// If key is not provided, read the existing key from the database
if existing, err := GetChannelById(channel.Id, true); err == nil {
keyStr = existing.Key
}
}
// Parse the key list (supports newline separation or JSON array)
keys := []string{}
if keyStr != "" {
trimmed := strings.TrimSpace(keyStr)
if strings.HasPrefix(trimmed, "[") {
var arr []json.RawMessage
if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
keys = make([]string, len(arr))
for i, v := range arr {
keys[i] = string(v)
}
}
}
if len(keys) == 0 { // fallback to newline split
keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
}
}
channel.ChannelInfo.MultiKeySize = len(keys)
// Clean up status data that exceeds the new key count to prevent index out of range
if channel.ChannelInfo.MultiKeyStatusList != nil {
for idx := range channel.ChannelInfo.MultiKeyStatusList {
if idx >= channel.ChannelInfo.MultiKeySize {
delete(channel.ChannelInfo.MultiKeyStatusList, idx)
}
}
}
}
var err error
err = DB.Model(channel).Updates(channel).Error
if err != nil {
@@ -404,48 +488,128 @@ func (channel *Channel) Delete() error {
var channelStatusLock sync.Mutex
func UpdateChannelStatusById(id int, status int, reason string) bool {
// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling
var channelPollingLocks sync.Map
// getChannelPollingLock returns or creates a mutex for the given channel ID
func getChannelPollingLock(channelId int) *sync.Mutex {
if lock, exists := channelPollingLocks.Load(channelId); exists {
return lock.(*sync.Mutex)
}
// Create new lock for this channel
newLock := &sync.Mutex{}
actual, _ := channelPollingLocks.LoadOrStore(channelId, newLock)
return actual.(*sync.Mutex)
}
// CleanupChannelPollingLocks removes locks for channels that no longer exist
// This is optional and can be called periodically to prevent memory leaks
func CleanupChannelPollingLocks() {
var activeChannelIds []int
DB.Model(&Channel{}).Pluck("id", &activeChannelIds)
activeChannelSet := make(map[int]bool)
for _, id := range activeChannelIds {
activeChannelSet[id] = true
}
channelPollingLocks.Range(func(key, value interface{}) bool {
channelId := key.(int)
if !activeChannelSet[channelId] {
channelPollingLocks.Delete(channelId)
}
return true
})
}
func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
keys := channel.getKeys()
if len(keys) == 0 {
channel.Status = status
} else {
var keyIndex int
for i, key := range keys {
if key == usingKey {
keyIndex = i
break
}
}
if channel.ChannelInfo.MultiKeyStatusList == nil {
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
}
if status == common.ChannelStatusEnabled {
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
} else {
channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
}
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
channel.Status = common.ChannelStatusAutoDisabled
info := channel.GetOtherInfo()
info["status_reason"] = "All keys are disabled"
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
}
}
}
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
if common.MemoryCacheEnabled {
channelStatusLock.Lock()
defer channelStatusLock.Unlock()
channelCache, _ := CacheGetChannel(id)
// 如果缓存渠道存在,且状态已是目标状态,直接返回
if channelCache != nil && channelCache.Status == status {
channelCache, _ := CacheGetChannel(channelId)
if channelCache == nil {
return false
}
// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
if channelCache == nil && status != common.ChannelStatusEnabled {
return false
if channelCache.ChannelInfo.IsMultiKey {
// 如果是多Key模式更新缓存中的状态
handlerMultiKeyUpdate(channelCache, usingKey, status)
//CacheUpdateChannel(channelCache)
//return true
} else {
// 如果缓存渠道存在,且状态已是目标状态,直接返回
if channelCache.Status == status {
return false
}
// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
if status != common.ChannelStatusEnabled {
return false
}
CacheUpdateChannelStatus(channelId, status)
}
CacheUpdateChannelStatus(id, status)
}
err := UpdateAbilityStatus(id, status == common.ChannelStatusEnabled)
shouldUpdateAbilities := false
defer func() {
if shouldUpdateAbilities {
err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
}
}
}()
channel, err := GetChannelById(channelId, true)
if err != nil {
common.SysError("failed to update ability status: " + err.Error())
return false
}
channel, err := GetChannelById(id, true)
if err != nil {
// find channel by id error, directly update status
result := DB.Model(&Channel{}).Where("id = ?", id).Update("status", status)
if result.Error != nil {
common.SysError("failed to update channel status: " + result.Error.Error())
return false
}
if result.RowsAffected == 0 {
return false
}
} else {
if channel.Status == status {
return false
}
// find channel by id success, update status and other info
info := channel.GetOtherInfo()
info["status_reason"] = reason
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
channel.Status = status
if channel.ChannelInfo.IsMultiKey {
beforeStatus := channel.Status
handlerMultiKeyUpdate(channel, usingKey, status)
if beforeStatus != channel.Status {
shouldUpdateAbilities = true
}
} else {
info := channel.GetOtherInfo()
info["status_reason"] = reason
info["status_time"] = common.GetTimestamp()
channel.SetOtherInfo(info)
channel.Status = status
shouldUpdateAbilities = true
}
err = channel.Save()
if err != nil {
common.SysError("failed to update channel status: " + err.Error())
@@ -628,6 +792,8 @@ func (channel *Channel) GetSetting() dto.ChannelSettings {
err := json.Unmarshal([]byte(*channel.Setting), &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
channel.Setting = nil // 清空设置以避免后续错误
_ = channel.Save() // 保存修改
}
}
return setting

View File

@@ -14,8 +14,8 @@ import (
"github.com/gin-gonic/gin"
)
var group2model2channels map[string]map[string][]*Channel
var channelsIDM map[int]*Channel
var group2model2channels map[string]map[string][]int // enabled channel
var channelsIDM map[int]*Channel // all channels include disabled
var channelSyncLock sync.RWMutex
func InitChannelCache() {
@@ -24,7 +24,7 @@ func InitChannelCache() {
}
newChannelId2channel := make(map[int]*Channel)
var channels []*Channel
DB.Where("status = ?", common.ChannelStatusEnabled).Find(&channels)
DB.Find(&channels)
for _, channel := range channels {
newChannelId2channel[channel.Id] = channel
}
@@ -34,21 +34,22 @@ func InitChannelCache() {
for _, ability := range abilities {
groups[ability.Group] = true
}
newGroup2model2channels := make(map[string]map[string][]*Channel)
newChannelsIDM := make(map[int]*Channel)
newGroup2model2channels := make(map[string]map[string][]int)
for group := range groups {
newGroup2model2channels[group] = make(map[string][]*Channel)
newGroup2model2channels[group] = make(map[string][]int)
}
for _, channel := range channels {
newChannelsIDM[channel.Id] = channel
if channel.Status != common.ChannelStatusEnabled {
continue // skip disabled channels
}
groups := strings.Split(channel.Group, ",")
for _, group := range groups {
models := strings.Split(channel.Models, ",")
for _, model := range models {
if _, ok := newGroup2model2channels[group][model]; !ok {
newGroup2model2channels[group][model] = make([]*Channel, 0)
newGroup2model2channels[group][model] = make([]int, 0)
}
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel)
newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id)
}
}
}
@@ -57,7 +58,7 @@ func InitChannelCache() {
for group, model2channels := range newGroup2model2channels {
for model, channels := range model2channels {
sort.Slice(channels, func(i, j int) bool {
return channels[i].GetPriority() > channels[j].GetPriority()
return newChannelId2channel[channels[i]].GetPriority() > newChannelId2channel[channels[j]].GetPriority()
})
newGroup2model2channels[group][model] = channels
}
@@ -65,7 +66,7 @@ func InitChannelCache() {
channelSyncLock.Lock()
group2model2channels = newGroup2model2channels
channelsIDM = newChannelsIDM
channelsIDM = newChannelId2channel
channelSyncLock.Unlock()
common.SysLog("channels synced from database")
}
@@ -128,16 +129,27 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
channels := group2model2channels[group][model]
channelSyncLock.RUnlock()
if len(channels) == 0 {
return nil, errors.New("channel not found")
}
if len(channels) == 1 {
if channel, ok := channelsIDM[channels[0]]; ok {
return channel, nil
}
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0])
}
uniquePriorities := make(map[int]bool)
for _, channel := range channels {
uniquePriorities[int(channel.GetPriority())] = true
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
uniquePriorities[int(channel.GetPriority())] = true
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
var sortedUniquePriorities []int
for priority := range uniquePriorities {
@@ -152,9 +164,13 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
// get the priority for the given retry number
var targetChannels []*Channel
for _, channel := range channels {
if channel.GetPriority() == targetPriority {
targetChannels = append(targetChannels, channel)
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
if channel.GetPriority() == targetPriority {
targetChannels = append(targetChannels, channel)
}
} else {
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
}
}
@@ -188,11 +204,35 @@ func CacheGetChannel(id int) (*Channel, error) {
c, ok := channelsIDM[id]
if !ok {
return nil, errors.New(fmt.Sprintf("当前渠道# %d已不存在", id))
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return c, nil
}
func CacheGetChannelInfo(id int) (*ChannelInfo, error) {
if !common.MemoryCacheEnabled {
channel, err := GetChannelById(id, true)
if err != nil {
return nil, err
}
return &channel.ChannelInfo, nil
}
channelSyncLock.RLock()
defer channelSyncLock.RUnlock()
c, ok := channelsIDM[id]
if !ok {
return nil, fmt.Errorf("渠道# %d已不存在", id)
}
if c.Status != common.ChannelStatusEnabled {
return nil, fmt.Errorf("渠道# %d已被禁用", id)
}
return &c.ChannelInfo, nil
}
func CacheUpdateChannelStatus(id int, status int) {
if !common.MemoryCacheEnabled {
return
@@ -203,3 +243,20 @@ func CacheUpdateChannelStatus(id int, status int) {
channel.Status = status
}
}
func CacheUpdateChannel(channel *Channel) {
if !common.MemoryCacheEnabled {
return
}
channelSyncLock.Lock()
defer channelSyncLock.Unlock()
if channel == nil {
return
}
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
channelsIDM[channel.Id] = channel
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
}

View File

@@ -27,7 +27,7 @@ type Log struct {
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
UseTime int `json:"use_time" gorm:"default:0"`
IsStream bool `json:"is_stream" gorm:"default:false"`
IsStream bool `json:"is_stream"`
ChannelId int `json:"channel" gorm:"index"`
ChannelName string `json:"channel_name" gorm:"->"`
TokenId int `json:"token_id" gorm:"default:0;index"`

View File

@@ -74,6 +74,7 @@ func InitOptionMap() {
common.OptionMap["EpayId"] = ""
common.OptionMap["EpayKey"] = ""
common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
@@ -306,6 +307,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.EpayKey = value
case "Price":
setting.Price, _ = strconv.ParseFloat(value, 64)
case "USDExchangeRate":
setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
case "MinTopUp":
setting.MinTopUp, _ = strconv.Atoi(value)
case "TopupGroupRatio":

View File

@@ -20,8 +20,8 @@ type Token struct {
AccessedTime int64 `json:"accessed_time" gorm:"bigint"`
ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired
RemainQuota int `json:"remain_quota" gorm:"default:0"`
UnlimitedQuota bool `json:"unlimited_quota" gorm:"default:false"`
ModelLimitsEnabled bool `json:"model_limits_enabled" gorm:"default:false"`
UnlimitedQuota bool `json:"unlimited_quota"`
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"`
AllowIps *string `json:"allow_ips" gorm:"default:''"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota

View File

@@ -203,6 +203,9 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
}
}
func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
return doRequest(c, req, info)
}
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
var client *http.Client
var err error

View File

@@ -18,6 +18,12 @@ import (
"github.com/gin-gonic/gin"
)
const (
WebSearchMaxUsesLow = 1
WebSearchMaxUsesMedium = 5
WebSearchMaxUsesHigh = 10
)
func stopReasonClaude2OpenAI(reason string) string {
switch reason {
case "stop_sequence":
@@ -65,7 +71,7 @@ func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.Cla
}
func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
claudeTools := make([]dto.Tool, 0, len(textRequest.Tools))
claudeTools := make([]any, 0, len(textRequest.Tools))
for _, tool := range textRequest.Tools {
if params, ok := tool.Function.Parameters.(map[string]any); ok {
@@ -85,10 +91,62 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
}
claudeTool.InputSchema[s] = a
}
claudeTools = append(claudeTools, claudeTool)
claudeTools = append(claudeTools, &claudeTool)
}
}
// Web search tool
// https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool
if textRequest.WebSearchOptions != nil {
webSearchTool := dto.ClaudeWebSearchTool{
Type: "web_search_20250305",
Name: "web_search",
}
// 处理 user_location
if textRequest.WebSearchOptions.UserLocation != nil {
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
Type: "approximate", // 固定为 "approximate"
}
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
anthropicUserLocation.Timezone = timezone
}
if country, ok := approximateData["country"].(string); ok && country != "" {
anthropicUserLocation.Country = country
}
if region, ok := approximateData["region"].(string); ok && region != "" {
anthropicUserLocation.Region = region
}
if city, ok := approximateData["city"].(string); ok && city != "" {
anthropicUserLocation.City = city
}
}
}
webSearchTool.UserLocation = anthropicUserLocation
}
// 处理 search_context_size 转换为 max_uses
if textRequest.WebSearchOptions.SearchContextSize != "" {
switch textRequest.WebSearchOptions.SearchContextSize {
case "low":
webSearchTool.MaxUses = WebSearchMaxUsesLow
case "medium":
webSearchTool.MaxUses = WebSearchMaxUsesMedium
case "high":
webSearchTool.MaxUses = WebSearchMaxUsesHigh
}
}
claudeTools = append(claudeTools, &webSearchTool)
}
claudeRequest := dto.ClaudeRequest{
Model: textRequest.Model,
MaxTokens: textRequest.MaxTokens,
@@ -100,6 +158,14 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
Tools: claudeTools,
}
// 处理 tool_choice 和 parallel_tool_calls
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
claudeToolChoice := mapToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
if claudeToolChoice != nil {
claudeRequest.ToolChoice = claudeToolChoice
}
}
if claudeRequest.MaxTokens == 0 {
claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
}
@@ -124,6 +190,27 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
}
if textRequest.ReasoningEffort != "" {
switch textRequest.ReasoningEffort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](4096),
}
}
}
// 指定了 reasoning 参数,覆盖 budgetTokens
if textRequest.Reasoning != nil {
var reasoning openrouter.RequestReasoning
if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil {
@@ -645,6 +732,10 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
responseData = data
}
if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
common.IOCopyBytesGracefully(c, nil, responseData)
return nil
}
@@ -672,3 +763,51 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *r
}
return nil, claudeInfo.Usage
}
func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
var claudeToolChoice *dto.ClaudeToolChoice
// 处理 tool_choice 字符串值
if toolChoiceStr, ok := toolChoice.(string); ok {
switch toolChoiceStr {
case "auto":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
case "required":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "any",
}
case "none":
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "none",
}
}
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
// 处理 tool_choice 对象值
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
if toolName, ok := function["name"].(string); ok {
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "tool",
Name: toolName,
}
}
}
}
// 处理 parallel_tool_calls
if parallelToolCalls != nil {
if claudeToolChoice == nil {
// 如果没有 tool_choice但有 parallel_tool_calls创建默认的 auto 类型
claudeToolChoice = &dto.ClaudeToolChoice{
Type: "auto",
}
}
// 设置 disable_parallel_tool_use
// 如果 parallel_tool_calls 为 true则 disable_parallel_tool_use 为 false
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
}
return claudeToolChoice
}

View File

@@ -74,12 +74,12 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeRerank {
err, usage = cohereRerankHandler(c, resp, info)
usage, err = cohereRerankHandler(c, resp, info)
} else {
if info.IsStream {
err, usage = cohereStreamHandler(c, info, resp)
usage, err = cohereStreamHandler(c, info, resp) // TODO: fix this
} else {
err, usage = cohereHandler(c, info, resp)
usage, err = cohereHandler(c, info, resp)
}
}
return

View File

@@ -78,7 +78,7 @@ func stopReasonCohere2OpenAI(reason string) string {
}
}
func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseId := helper.GetResponseID(c)
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
@@ -166,20 +166,20 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
if usage.PromptTokens == 0 {
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
return nil, usage
return usage, nil
}
func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
createdTime := common.GetTimestamp()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
var cohereResp CohereResponseResult
err = json.Unmarshal(responseBody, &cohereResp)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
usage := dto.Usage{}
usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens
@@ -203,24 +203,24 @@ func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
jsonResponse, err := json.Marshal(openaiResp)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &usage
_, _ = c.Writer.Write(jsonResponse)
return &usage, nil
}
func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
var cohereResp CohereRerankResponseResult
err = json.Unmarshal(responseBody, &cohereResp)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
usage := dto.Usage{}
if cohereResp.Meta.BilledUnits.InputTokens == 0 {
@@ -239,10 +239,10 @@ func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.
jsonResponse, err := json.Marshal(rerankResp)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &usage
return &usage, nil
}

View File

@@ -98,9 +98,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *common.RelayInfo, requestBody
// DoResponse implements channel.Adaptor.
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *common.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
err, usage = cozeChatStreamHandler(c, info, resp)
usage, err = cozeChatStreamHandler(c, info, resp)
} else {
err, usage = cozeChatHandler(c, info, resp)
usage, err = cozeChatHandler(c, info, resp)
}
return
}

View File

@@ -44,10 +44,10 @@ func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *C
return cozeRequest
}
func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.CloseResponseBodyGracefully(resp)
// convert coze response to openai response
@@ -56,10 +56,10 @@ func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
response.Model = info.UpstreamModelName
err = json.Unmarshal(responseBody, &cozeResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if cozeResponse.Code != 0 {
return types.NewError(errors.New(cozeResponse.Msg), types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(errors.New(cozeResponse.Msg), types.ErrorCodeBadResponseBody)
}
// 从上下文获取 usage
var usage dto.Usage
@@ -86,16 +86,16 @@ func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
}
jsonResponse, err := json.Marshal(response)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, _ = c.Writer.Write(jsonResponse)
return nil, &usage
return &usage, nil
}
func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
@@ -136,7 +136,7 @@ func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
}
if err := scanner.Err(); err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
helper.Done(c)
@@ -144,7 +144,7 @@ func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
}
return nil, usage
return usage, nil
}
func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) {

View File

@@ -0,0 +1,136 @@
package jimeng
import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
)
type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/?Action=CVProcess&Version=2022-08-31", info.BaseUrl), nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
return errors.New("not implemented")
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
type LogoInfo struct {
AddLogo bool `json:"add_logo,omitempty"`
Position int `json:"position,omitempty"`
Language int `json:"language,omitempty"`
Opacity float64 `json:"opacity,omitempty"`
LogoTextContent string `json:"logo_text_content,omitempty"`
}
type imageRequestPayload struct {
ReqKey string `json:"req_key"` // Service identifier, fixed value: jimeng_high_aes_general_v21_L
Prompt string `json:"prompt"` // Prompt for image generation, supports both Chinese and English
Seed int64 `json:"seed,omitempty"` // Random seed, default -1 (random)
Width int `json:"width,omitempty"` // Image width, default 512, range [256, 768]
Height int `json:"height,omitempty"` // Image height, default 512, range [256, 768]
UsePreLLM bool `json:"use_pre_llm,omitempty"` // Enable text expansion, default true
UseSR bool `json:"use_sr,omitempty"` // Enable super resolution, default true
ReturnURL bool `json:"return_url,omitempty"` // Whether to return image URL (valid for 24 hours)
LogoInfo LogoInfo `json:"logo_info,omitempty"` // Watermark information
ImageUrls []string `json:"image_urls,omitempty"` // Image URLs for input
BinaryData []string `json:"binary_data_base64,omitempty"` // Base64 encoded binary data
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
payload := imageRequestPayload{
ReqKey: request.Model,
Prompt: request.Prompt,
}
if request.ResponseFormat == "" || request.ResponseFormat == "url" {
payload.ReturnURL = true // Default to returning image URLs
}
if len(request.ExtraFields) > 0 {
if err := json.Unmarshal(request.ExtraFields, &payload); err != nil {
return nil, fmt.Errorf("failed to unmarshal extra fields: %w", err)
}
}
return payload, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return nil, errors.New("not implemented")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
fullRequestURL, err := a.GetRequestURL(info)
if err != nil {
return nil, fmt.Errorf("get request url failed: %w", err)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
err = Sign(c, req, info.ApiKey)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
resp, err := channel.DoRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
}
return resp, nil
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == relayconstant.RelayModeImagesGenerations {
usage, err = jimengImageHandler(c, resp, info)
} else if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
}
return
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}

View File

@@ -0,0 +1,9 @@
package jimeng
const (
ChannelName = "jimeng"
)
var ModelList = []string{
"jimeng_high_aes_general_v21_L",
}

View File

@@ -0,0 +1,89 @@
package jimeng
import (
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/types"
"github.com/gin-gonic/gin"
)
type ImageResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
BinaryDataBase64 []string `json:"binary_data_base64"`
ImageUrls []string `json:"image_urls"`
RephraseResult string `json:"rephraser_result"`
RequestID string `json:"request_id"`
// Other fields are omitted for brevity
} `json:"data"`
RequestID string `json:"request_id"`
Status int `json:"status"`
TimeElapsed string `json:"time_elapsed"`
}
func responseJimeng2OpenAIImage(_ *gin.Context, response *ImageResponse, info *relaycommon.RelayInfo) *dto.ImageResponse {
imageResponse := dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, base64Data := range response.Data.BinaryDataBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{
B64Json: base64Data,
})
}
for _, imageUrl := range response.Data.ImageUrls {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{
Url: imageUrl,
})
}
return &imageResponse
}
// jimengImageHandler handles the Jimeng image generation response
func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
var jimengResponse ImageResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
}
common.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &jimengResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
// Check if the response indicates an error
if jimengResponse.Code != 10000 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: jimengResponse.Message,
Type: "jimeng_error",
Param: "",
Code: fmt.Sprintf("%d", jimengResponse.Code),
}, resp.StatusCode)
}
// Convert Jimeng response to OpenAI format
fullTextResponse := responseJimeng2OpenAIImage(c, &jimengResponse, info)
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}

View File

@@ -0,0 +1,176 @@
package jimeng
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"net/url"
"one-api/common"
"sort"
"strings"
"time"
)
// SignRequestForJimeng 对即梦 API 请求进行签名,支持 http.Request 或 header+url+body 方式
//func SignRequestForJimeng(req *http.Request, accessKey, secretKey string) error {
// var bodyBytes []byte
// var err error
//
// if req.Body != nil {
// bodyBytes, err = io.ReadAll(req.Body)
// if err != nil {
// return fmt.Errorf("read request body failed: %w", err)
// }
// _ = req.Body.Close()
// req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // rewind
// } else {
// bodyBytes = []byte{}
// }
//
// return signJimengHeaders(&req.Header, req.Method, req.URL, bodyBytes, accessKey, secretKey)
//}
const HexPayloadHashKey = "HexPayloadHash"
func SetPayloadHash(c *gin.Context, req any) error {
body, err := json.Marshal(req)
if err != nil {
return err
}
common.LogInfo(c, fmt.Sprintf("SetPayloadHash body: %s", body))
payloadHash := sha256.Sum256(body)
hexPayloadHash := hex.EncodeToString(payloadHash[:])
c.Set(HexPayloadHashKey, hexPayloadHash)
return nil
}
func getPayloadHash(c *gin.Context) string {
return c.GetString(HexPayloadHashKey)
}
func Sign(c *gin.Context, req *http.Request, apiKey string) error {
header := req.Header
var bodyBytes []byte
var err error
if req.Body != nil {
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return err
}
_ = req.Body.Close()
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind
}
payloadHash := sha256.Sum256(bodyBytes)
hexPayloadHash := hex.EncodeToString(payloadHash[:])
method := c.Request.Method
u := req.URL
keyParts := strings.Split(apiKey, "|")
if len(keyParts) != 2 {
return errors.New("invalid api key format for jimeng: expected 'ak|sk'")
}
accessKey := strings.TrimSpace(keyParts[0])
secretKey := strings.TrimSpace(keyParts[1])
t := time.Now().UTC()
xDate := t.Format("20060102T150405Z")
shortDate := t.Format("20060102")
host := u.Host
header.Set("Host", host)
header.Set("X-Date", xDate)
header.Set("X-Content-Sha256", hexPayloadHash)
// Sort and encode query parameters to create canonical query string
queryParams := u.Query()
sortedKeys := make([]string, 0, len(queryParams))
for k := range queryParams {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
var queryParts []string
for _, k := range sortedKeys {
values := queryParams[k]
sort.Strings(values)
for _, v := range values {
queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v)))
}
}
canonicalQueryString := strings.Join(queryParts, "&")
headersToSign := map[string]string{
"host": host,
"x-date": xDate,
"x-content-sha256": hexPayloadHash,
}
if header.Get("Content-Type") == "" {
header.Set("Content-Type", "application/json")
}
headersToSign["content-type"] = header.Get("Content-Type")
var signedHeaderKeys []string
for k := range headersToSign {
signedHeaderKeys = append(signedHeaderKeys, k)
}
sort.Strings(signedHeaderKeys)
var canonicalHeaders strings.Builder
for _, k := range signedHeaderKeys {
canonicalHeaders.WriteString(k)
canonicalHeaders.WriteString(":")
canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k]))
canonicalHeaders.WriteString("\n")
}
signedHeaders := strings.Join(signedHeaderKeys, ";")
canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
method,
u.Path,
canonicalQueryString,
canonicalHeaders.String(),
signedHeaders,
hexPayloadHash,
)
hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest))
hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:])
region := "cn-north-1"
serviceName := "cv"
credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName)
stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s",
xDate,
credentialScope,
hexHashedCanonicalRequest,
)
kDate := hmacSHA256([]byte(secretKey), []byte(shortDate))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(serviceName))
kSigning := hmacSHA256(kService, []byte("request"))
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
accessKey,
credentialScope,
signedHeaders,
signature,
)
header.Set("Authorization", authorization)
return nil
}
// hmacSHA256 计算 HMAC-SHA256
func hmacSHA256(key []byte, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}

View File

@@ -96,7 +96,7 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if ollamaEmbeddingResponse.Error != "" {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
return nil, types.NewError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody)
}
flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding)
data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1)

View File

@@ -35,9 +35,9 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if !strings.Contains(request.Model, "claude") {
return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model)
}
//if !strings.Contains(request.Model, "claude") {
// return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model)
//}
aiRequest, err := service.ClaudeToOpenAIRequest(*request, info)
if err != nil {
return nil, err

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
@@ -63,7 +64,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
apiKey := c.Request.Header.Get("Authorization")
apiKey := common.GetContextKeyString(c, constant.ContextKeyChannelKey)
apiKey = strings.TrimPrefix(apiKey, "Bearer ")
appId, secretId, secretKey, err := parseTencentConfig(apiKey)
a.AppID = appId

View File

@@ -57,6 +57,15 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
if strings.HasSuffix(info.UpstreamModelName, "-search") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
request.Model = info.UpstreamModelName
toMap := request.ToMap()
toMap["search_parameters"] = map[string]any{
"mode": "on",
}
return toMap, nil
}
if strings.HasPrefix(request.Model, "grok-3-mini") {
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
request.MaxCompletionTokens = request.MaxTokens

View File

@@ -1,6 +1,8 @@
package xai
var ModelList = []string{
// grok-4
"grok-4", "grok-4-0709", "grok-4-0709-search",
// grok-3
"grok-3-beta", "grok-3-mini-beta",
// grok-3 mini

View File

@@ -4,24 +4,24 @@ import "one-api/dto"
// ChatCompletionResponse represents the response from XAI chat completion API
type ChatCompletionResponse struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []dto.ChatCompletionsStreamResponseChoice
Usage *dto.Usage `json:"usage"`
SystemFingerprint string `json:"system_fingerprint"`
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []dto.OpenAITextResponseChoice `json:"choices"`
Usage *dto.Usage `json:"usage"`
SystemFingerprint string `json:"system_fingerprint"`
}
// quality, size or style are not supported by xAI API at the moment.
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
N int `json:"n,omitempty"`
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
N int `json:"n,omitempty"`
// Size string `json:"size,omitempty"`
// Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
// Style string `json:"style,omitempty"`
// User string `json:"user,omitempty"`
// ExtraFields json.RawMessage `json:"extra_fields,omitempty"`
}
}

View File

@@ -82,21 +82,26 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response
defer common.CloseResponseBodyGracefully(resp)
responseBody, err := io.ReadAll(resp.Body)
var response *dto.SimpleResponse
err = common.Unmarshal(responseBody, &response)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
response.Usage.CompletionTokens = response.Usage.TotalTokens - response.Usage.PromptTokens
response.Usage.CompletionTokenDetails.TextTokens = response.Usage.CompletionTokens - response.Usage.CompletionTokenDetails.ReasoningTokens
var xaiResponse ChatCompletionResponse
err = common.Unmarshal(responseBody, &xaiResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
if xaiResponse.Usage != nil {
xaiResponse.Usage.CompletionTokens = xaiResponse.Usage.TotalTokens - xaiResponse.Usage.PromptTokens
xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens
}
// new body
encodeJson, err := common.Marshal(response)
encodeJson, err := common.Marshal(xaiResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
common.IOCopyBytesGracefully(c, resp, encodeJson)
return &response.Usage, nil
return xaiResponse.Usage, nil
}

View File

@@ -247,7 +247,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
IsModelMapped: false,
ApiType: apiType,
ApiVersion: c.GetString("api_version"),
ApiKey: strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "),
ApiKey: common.GetContextKeyString(c, constant.ContextKeyChannelKey),
Organization: c.GetString("channel_organization"),
ChannelCreateTime: c.GetInt64("channel_create_time"),

View File

@@ -145,22 +145,25 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
} else {
sizeRatio := 1.0
// Size
if imageRequest.Size == "256x256" {
sizeRatio = 0.4
} else if imageRequest.Size == "512x512" {
sizeRatio = 0.45
} else if imageRequest.Size == "1024x1024" {
sizeRatio = 1
} else if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" {
sizeRatio = 2
}
qualityRatio := 1.0
if imageRequest.Model == "dall-e-3" && imageRequest.Quality == "hd" {
qualityRatio = 2.0
if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" {
qualityRatio = 1.5
if strings.HasPrefix(imageRequest.Model, "dall-e") {
// Size
if imageRequest.Size == "256x256" {
sizeRatio = 0.4
} else if imageRequest.Size == "512x512" {
sizeRatio = 0.45
} else if imageRequest.Size == "1024x1024" {
sizeRatio = 1
} else if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" {
sizeRatio = 2
}
if imageRequest.Model == "dall-e-3" && imageRequest.Quality == "hd" {
qualityRatio = 2.0
if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" {
qualityRatio = 1.5
}
}
}

View File

@@ -575,7 +575,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
common.SysError("get_channel_null: " + err.Error())
}
if channel.GetAutoBan() && common.AutomaticDisableChannelEnabled {
model.UpdateChannelStatusById(midjourneyTask.ChannelId, 2, "No available account instance")
model.UpdateChannelStatus(midjourneyTask.ChannelId, "", 2, "No available account instance")
}
}
if midjResponse.Code != 1 && midjResponse.Code != 21 && midjResponse.Code != 22 {

View File

@@ -379,6 +379,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
// openai web search 工具计费
var dWebSearchQuota decimal.Decimal
var webSearchPrice float64
// response api 格式工具计费
if relayInfo.ResponsesUsageInfo != nil {
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 {
// 计算 web search 调用的配额 (配额 = 价格 * 调用次数 / 1000 * 分组倍率)
@@ -401,6 +402,17 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s调用花费 %s",
searchContextSize, dWebSearchQuota.String())
}
// claude web search tool 计费
var dClaudeWebSearchQuota decimal.Decimal
var claudeWebSearchPrice float64
claudeWebSearchCallCount := ctx.GetInt("claude_web_search_requests")
if claudeWebSearchCallCount > 0 {
claudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand()
dClaudeWebSearchQuota = decimal.NewFromFloat(claudeWebSearchPrice).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).Mul(decimal.NewFromInt(int64(claudeWebSearchCallCount)))
extraContent += fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s",
claudeWebSearchCallCount, dClaudeWebSearchQuota.String())
}
// file search tool 计费
var dFileSearchQuota decimal.Decimal
var fileSearchPrice float64
@@ -524,6 +536,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other["web_search_call_count"] = 1
other["web_search_price"] = webSearchPrice
}
} else if !dClaudeWebSearchQuota.IsZero() {
other["web_search"] = true
other["web_search_call_count"] = claudeWebSearchCallCount
other["web_search_price"] = claudeWebSearchPrice
}
if !dFileSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil {
if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists {

View File

@@ -15,6 +15,7 @@ import (
"one-api/relay/channel/deepseek"
"one-api/relay/channel/dify"
"one-api/relay/channel/gemini"
"one-api/relay/channel/jimeng"
"one-api/relay/channel/jina"
"one-api/relay/channel/mistral"
"one-api/relay/channel/mokaai"
@@ -23,7 +24,7 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
"one-api/relay/channel/task/jimeng"
taskjimeng "one-api/relay/channel/task/jimeng"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
"one-api/relay/channel/tencent"
@@ -93,6 +94,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &xai.Adaptor{}
case constant.APITypeCoze:
return &coze.Adaptor{}
case constant.APITypeJimeng:
return &jimeng.Adaptor{}
}
return nil
}
@@ -106,7 +109,7 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor {
case commonconstant.TaskPlatformKling:
return &kling.TaskAdaptor{}
case commonconstant.TaskPlatformJimeng:
return &jimeng.TaskAdaptor{}
return &taskjimeng.TaskAdaptor{}
}
return nil
}

View File

@@ -37,9 +37,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
return
}
modelName := service.CoverTaskActionToModelName(platform, relayInfo.Action)
if platform == constant.TaskPlatformKling {
modelName = relayInfo.OriginModelName
modelName := relayInfo.OriginModelName
if modelName == "" {
modelName = service.CoverTaskActionToModelName(platform, relayInfo.Action)
}
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
if !success {

View File

@@ -115,6 +115,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/fetch_models", controller.FetchModels)
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
channelRoute.GET("/tag/models", controller.GetTagModels)
channelRoute.POST("/copy/:id", controller.CopyChannel)
}
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())

View File

@@ -17,17 +17,17 @@ func formatNotifyType(channelId int, status int) string {
}
// disable & notify
func DisableChannel(channelId int, channelName string, reason string) {
success := model.UpdateChannelStatusById(channelId, common.ChannelStatusAutoDisabled, reason)
func DisableChannel(channelError types.ChannelError, reason string) {
success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason)
if success {
subject := fmt.Sprintf("通道「%s」#%d已被禁用", channelName, channelId)
content := fmt.Sprintf("通道「%s」#%d已被禁用原因%s", channelName, channelId, reason)
NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusAutoDisabled), subject, content)
subject := fmt.Sprintf("通道「%s」#%d已被禁用", channelError.ChannelName, channelError.ChannelId)
content := fmt.Sprintf("通道「%s」#%d已被禁用原因%s", channelError.ChannelName, channelError.ChannelId, reason)
NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content)
}
}
func EnableChannel(channelId int, channelName string) {
success := model.UpdateChannelStatusById(channelId, common.ChannelStatusEnabled, "")
func EnableChannel(channelId int, usingKey string, channelName string) {
success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "")
if success {
subject := fmt.Sprintf("通道「%s」#%d已被启用", channelName, channelId)
content := fmt.Sprintf("通道「%s」#%d已被启用", channelName, channelId)
@@ -87,13 +87,10 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
return search
}
func ShouldEnableChannel(err error, newAPIError *types.NewAPIError, status int) bool {
func ShouldEnableChannel(newAPIError *types.NewAPIError, status int) bool {
if !common.AutomaticEnableChannelEnabled {
return false
}
if err != nil {
return false
}
if newAPIError != nil {
return false
}

View File

@@ -1,6 +1,8 @@
package service
import (
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
@@ -28,6 +30,11 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
}
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = ctx.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex)
}
other["admin_info"] = adminInfo
return other
}

View File

@@ -204,7 +204,7 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
req = req.WithContext(ctx)
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept"))
auth := c.Request.Header.Get("Authorization")
auth := common.GetContextKeyString(c, constant.ContextKeyChannelKey)
if auth != "" {
auth = strings.TrimPrefix(auth, "Bearer ")
req.Header.Set("mj-api-secret", auth)

View File

@@ -326,7 +326,7 @@ func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData
promptCacheReadPrice := quotaPrice * priceData.CacheRatio
completionPrice := quotaPrice * priceData.CompletionRatio
cost := usage.Cost
cost, _ := usage.Cost.(float64)
totalPromptTokens := float64(usage.PromptTokens)
completionTokens := float64(usage.CompletionTokens)
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)

View File

@@ -23,6 +23,15 @@ const (
Gemini20FlashInputAudioPrice = 0.70
)
const (
// Claude Web search
ClaudeWebSearchPrice = 10.00
)
func GetClaudeWebSearchPricePerThousand() float64 {
return ClaudeWebSearchPrice
}
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
// 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费

View File

@@ -8,6 +8,7 @@ var EpayId = ""
var EpayKey = ""
var Price = 7.3
var MinTopUp = 1
var USDExchangeRate = 7.3
var PayMethods = []map[string]string{
{

View File

@@ -3,14 +3,19 @@ package setting
import (
"encoding/json"
"one-api/common"
"sync"
)
var userUsableGroups = map[string]string{
"default": "默认分组",
"vip": "vip分组",
}
var userUsableGroupsMutex sync.RWMutex
func GetUserUsableGroupsCopy() map[string]string {
userUsableGroupsMutex.RLock()
defer userUsableGroupsMutex.RUnlock()
copyUserUsableGroups := make(map[string]string)
for k, v := range userUsableGroups {
copyUserUsableGroups[k] = v
@@ -19,6 +24,9 @@ func GetUserUsableGroupsCopy() map[string]string {
}
func UserUsableGroups2JSONString() string {
userUsableGroupsMutex.RLock()
defer userUsableGroupsMutex.RUnlock()
jsonBytes, err := json.Marshal(userUsableGroups)
if err != nil {
common.SysError("error marshalling user groups: " + err.Error())
@@ -27,6 +35,9 @@ func UserUsableGroups2JSONString() string {
}
func UpdateUserUsableGroupsByJSONString(jsonStr string) error {
userUsableGroupsMutex.Lock()
defer userUsableGroupsMutex.Unlock()
userUsableGroups = make(map[string]string)
return json.Unmarshal([]byte(jsonStr), &userUsableGroups)
}
@@ -47,11 +58,17 @@ func GetUserUsableGroups(userGroup string) map[string]string {
}
func GroupInUserUsableGroups(groupName string) bool {
userUsableGroupsMutex.RLock()
defer userUsableGroupsMutex.RUnlock()
_, ok := userUsableGroups[groupName]
return ok
}
func GetUsableGroupDescription(groupName string) string {
userUsableGroupsMutex.RLock()
defer userUsableGroupsMutex.RUnlock()
if desc, ok := userUsableGroups[groupName]; ok {
return desc
}

21
types/channel_error.go Normal file
View File

@@ -0,0 +1,21 @@
package types
type ChannelError struct {
ChannelId int `json:"channel_id"`
ChannelType int `json:"channel_type"`
ChannelName string `json:"channel_name"`
IsMultiKey bool `json:"is_multi_key"`
AutoBan bool `json:"auto_ban"`
UsingKey string `json:"using_key"`
}
func NewChannelError(channelId int, channelType int, channelName string, isMultiKey bool, usingKey string, autoBan bool) *ChannelError {
return &ChannelError{
ChannelId: channelId,
ChannelType: channelType,
ChannelName: channelName,
IsMultiKey: isMultiKey,
AutoBan: autoBan,
UsingKey: usingKey,
}
}

View File

@@ -50,6 +50,7 @@ const (
ErrorCodeChannelModelMappedError ErrorCode = "channel:model_mapped_error"
ErrorCodeChannelAwsClientError ErrorCode = "channel:aws_client_error"
ErrorCodeChannelInvalidKey ErrorCode = "channel:invalid_key"
ErrorCodeChannelResponseTimeExceeded ErrorCode = "channel:response_time_exceeded"
// client request error
ErrorCodeReadRequestBodyFailed ErrorCode = "read_request_body_failed"
@@ -87,6 +88,13 @@ func (e *NewAPIError) GetErrorCode() ErrorCode {
}
func (e *NewAPIError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
// fallback message when underlying error is missing
return string(e.errorCode)
}
return e.Err.Error()
}

2010
web/bun.lock Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
@@ -7,22 +7,28 @@ import Loading from '../common/Loading';
const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const [, userDispatch] = useContext(UserContext);
const navigate = useNavigate();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState(t('处理中...'));
// 最大重试次数
const MAX_RETRIES = 3;
let navigate = useNavigate();
const sendCode = async (code, state, retry = 0) => {
try {
const { data: resData } = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = resData;
if (!success) {
throw new Error(message || 'OAuth2 callback error');
}
const sendCode = async (code, state, count) => {
const res = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/setting');
navigate('/console/personal');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
@@ -31,27 +37,34 @@ const OAuth2Callback = (props) => {
showSuccess(t('登录成功!'));
navigate('/console/token');
}
} else {
showError(message);
if (count === 0) {
setPrompt(t('操作失败,重定向至登录界面中...'));
navigate('/console/setting'); // in case this is failed to bind GitHub
return;
} catch (error) {
if (retry < MAX_RETRIES) {
// 递增的退避等待
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
return sendCode(code, state, retry + 1);
}
count++;
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
// 重试次数耗尽,提示错误并返回设置页面
showError(error.message || t('授权失败'));
navigate('/console/personal');
}
};
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
const code = searchParams.get('code');
const state = searchParams.get('state');
// 参数缺失直接返回
if (!code) {
showError(t('未获取到授权码'));
navigate('/console/personal');
return;
}
sendCode(code, state);
}, []);
return <Loading prompt={prompt} />;
return <Loading />;
};
export default OAuth2Callback;

View File

@@ -1,22 +1,14 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const Loading = ({ prompt: name = '', size = 'large' }) => {
const { t } = useTranslation();
const Loading = ({ size = 'small' }) => {
return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size={size}
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('{{name}}', { name }) : t('加载中...')}
</span>
</div>
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
<Spin
size={size}
spinning={true}
/>
</div>
);
};

View File

@@ -22,7 +22,7 @@ const FooterBar = () => {
const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => (
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<footer className="relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
@@ -31,13 +31,15 @@ import {
Badge,
} from '@douyinfe/semi-ui';
import { StatusContext } from '../../context/Status/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
const HeaderBar = () => {
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState, dispatch: styleDispatch } = useStyle();
const isMobile = useIsMobile();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [isLoading, setIsLoading] = useState(true);
let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
@@ -45,6 +47,7 @@ const HeaderBar = () => {
const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const loadingStartRef = useRef(Date.now());
const systemName = getSystemName();
const logo = getLogo();
@@ -194,11 +197,15 @@ const HeaderBar = () => {
}, [i18n]);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
if (statusState?.status !== undefined) {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const timer = setTimeout(() => {
setIsLoading(false);
}, remaining);
return () => clearTimeout(timer);
}
}, [statusState?.status]);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
@@ -207,7 +214,7 @@ const HeaderBar = () => {
const handleNavLinkClick = (itemKey) => {
if (itemKey === 'home') {
styleDispatch(styleActions.setSider(false));
// styleDispatch(styleActions.setSider(false)); // This line is removed
}
setMobileMenuOpen(false);
};
@@ -221,7 +228,16 @@ const HeaderBar = () => {
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobileView ? 100 : 60, height: 16 }}
/>
}
/>
</div>
));
}
@@ -272,9 +288,22 @@ const HeaderBar = () => {
if (isLoading) {
return (
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
<Skeleton
loading={true}
active
placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
/>
<div className="ml-1.5 mr-1">
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 15 : 50, height: 12 }}
/>
}
/>
</div>
</div>
);
@@ -366,7 +395,7 @@ const HeaderBar = () => {
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
if (showRegisterButton) {
if (styleState.isMobile) {
if (isMobile) {
loginButtonClasses += " !rounded-full";
} else {
loginButtonClasses += " !rounded-l-full !rounded-r-none";
@@ -414,7 +443,7 @@ const HeaderBar = () => {
<NoticeModal
visible={noticeVisible}
onClose={handleNoticeClose}
isMobile={styleState.isMobile}
isMobile={isMobile}
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
unreadKeys={getUnreadKeys()}
/>
@@ -425,18 +454,18 @@ const HeaderBar = () => {
<Button
icon={
isConsoleRoute
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
? ((isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
}
aria-label={
isConsoleRoute
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
? ((isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏'))
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
}
onClick={() => {
if (isConsoleRoute) {
// 控制侧边栏的显示/隐藏,无论是否移动设备
styleDispatch(styleActions.toggleSider());
isMobile ? onMobileMenuToggle() : toggleCollapsed();
} else {
// 控制HeaderBar自己的移动菜单
setMobileMenuOpen(!mobileMenuOpen);
@@ -448,22 +477,35 @@ const HeaderBar = () => {
/>
</div>
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
{isLoading ? (
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
) : (
<Skeleton
loading={isLoading}
active
placeholder={
<Skeleton.Image
active
className="h-7 md:h-8 !rounded-full"
style={{ width: 32, height: 32 }}
/>
}
>
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
)}
</Skeleton>
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2">
{isLoading ? (
<Skeleton.Title style={{ width: 120, height: 24 }} />
) : (
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
bg-clip-text text-transparent">
<Skeleton
loading={isLoading}
active
placeholder={
<Skeleton.Title
active
style={{ width: 120, height: 24 }}
/>
}
>
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
{systemName}
</Typography.Title>
)}
</Skeleton>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}

View File

@@ -4,8 +4,9 @@ import SiderBar from './SiderBar.js';
import App from '../../App.js';
import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react';
import { useStyle } from '../../context/Style/index.js';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { UserContext } from '../../context/User/index.js';
@@ -14,9 +15,11 @@ import { useLocation } from 'react-router-dom';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState } = useStyle();
const [, userDispatch] = useContext(UserContext);
const [, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, , setCollapsed] = useSidebarCollapsed();
const [drawerOpen, setDrawerOpen] = useState(false);
const { i18n } = useTranslation();
const location = useLocation();
@@ -26,6 +29,15 @@ const PageLayout = () => {
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
setCollapsed(false);
}
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
@@ -63,7 +75,6 @@ const PageLayout = () => {
linkElement.href = logo;
}
}
// 从localStorage获取上次使用的语言
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
i18n.changeLanguage(savedLang);
@@ -76,7 +87,7 @@ const PageLayout = () => {
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: styleState.isMobile ? 'visible' : 'hidden',
overflow: isMobile ? 'visible' : 'hidden',
}}
>
<Header
@@ -90,16 +101,16 @@ const PageLayout = () => {
zIndex: 100,
}}
>
<HeaderBar />
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
</Header>
<Layout
style={{
overflow: styleState.isMobile ? 'visible' : 'auto',
overflow: isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{styleState.showSider && (
{showSider && (
<Sider
style={{
position: 'fixed',
@@ -109,21 +120,15 @@ const PageLayout = () => {
border: 'none',
paddingRight: '0',
height: 'calc(100vh - 64px)',
width: 'var(--sidebar-current-width)',
}}
>
<SiderBar />
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
</Sider>
)}
<Layout
style={{
marginLeft: styleState.isMobile
? '0'
: styleState.showSider
? styleState.siderCollapsed
? '60px'
: '180px'
: '0',
transition: 'margin-left 0.3s ease',
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
@@ -132,9 +137,9 @@ const PageLayout = () => {
<Content
style={{
flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'hidden',
overflowY: isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
}}
>

View File

@@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react';
import { useStyle, styleActions } from '../../context/Style/index.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
import {
isAdmin,
isRoot,
@@ -13,7 +13,7 @@ import {
import {
Nav,
Divider,
Tooltip,
Button,
} from '@douyinfe/semi-ui';
const routerMap = {
@@ -34,12 +34,11 @@ const routerMap = {
personal: '/console/personal',
};
const SiderBar = () => {
const SiderBar = ({ onNavigate = () => { } }) => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
@@ -217,10 +216,14 @@ const SiderBar = () => {
}
}, [location.pathname, routerMapState]);
// 同步折叠状态
// 监控折叠状态变化以更新 body class
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
if (collapsed) {
document.body.classList.add('sidebar-collapsed');
} else {
document.body.classList.remove('sidebar-collapsed');
}
}, [collapsed]);
// 获取菜单项对应的颜色
const getItemColor = (itemKey) => {
@@ -323,32 +326,13 @@ const SiderBar = () => {
return (
<div
className="sidebar-container"
style={{ width: isCollapsed ? '60px' : '180px' }}
style={{ width: 'var(--sidebar-current-width)' }}
>
<Nav
className="sidebar-nav custom-sidebar-nav"
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
className="sidebar-nav"
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
@@ -363,6 +347,7 @@ const SiderBar = () => {
<Link
style={{ textDecoration: 'none' }}
to={to}
onClick={onNavigate}
>
{itemElement}
</Link>
@@ -383,7 +368,7 @@ const SiderBar = () => {
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
@@ -392,7 +377,7 @@ const SiderBar = () => {
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
@@ -403,7 +388,7 @@ const SiderBar = () => {
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
@@ -414,7 +399,7 @@ const SiderBar = () => {
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
@@ -422,24 +407,25 @@ const SiderBar = () => {
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
<div className="sidebar-collapse-button">
<Button
theme="outline"
type="tertiary"
size="small"
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color="var(--semi-color-text-2)"
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
}
onClick={toggleCollapsed}
iconOnly={collapsed}
style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
</div>
</div>
);

View File

@@ -34,7 +34,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.temperature}
</Tag>
</div>
@@ -70,7 +70,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.top_p}
</Tag>
</div>
@@ -106,7 +106,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.frequency_penalty}
</Tag>
</div>
@@ -142,7 +142,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.presence_penalty}
</Tag>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { isMobile } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Modal,
Table,
@@ -26,6 +26,7 @@ const ChannelSelectorModal = forwardRef(({
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
const [filteredData, setFilteredData] = useState([]);
@@ -118,25 +119,25 @@ const ChannelSelectorModal = forwardRef(({
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -186,7 +187,7 @@ const ChannelSelectorModal = forwardRef(({
onCancel={onCancel}
onOk={onOk}
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
size={isMobile() ? 'full-width' : 'large'}
size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsChats from '../../pages/Setting/Chat/SettingsChats.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const ChatsSetting = () => {
let [inputs, setInputs] = useState({
@@ -21,7 +21,7 @@ const ChatsSetting = () => {
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
@@ -45,7 +45,7 @@ const DashboardSetting = () => {
}
if (item.key.endsWith('Enabled') &&
(item.key === 'DataExportEnabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
}
});
setInputs(newInputs);

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const DrawingSetting = () => {
let [inputs, setInputs] = useState({
@@ -23,7 +23,7 @@ const DrawingSetting = () => {
let newInputs = {};
data.forEach((item) => {
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
@@ -44,7 +44,7 @@ const ModelSetting = () => {
}
}
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -5,7 +5,7 @@ import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensit
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const OperationSetting = () => {
let [inputs, setInputs] = useState({
@@ -19,6 +19,7 @@ const OperationSetting = () => {
TopUpLink: '',
'general_setting.docs_link': '',
QuotaPerUnit: 0,
USDExchangeRate: 0,
RetryTimes: 0,
DisplayInCurrencyEnabled: false,
DisplayTokenStatEnabled: false,
@@ -54,7 +55,7 @@ const OperationSetting = () => {
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
const PaymentSetting = () => {
@@ -42,7 +42,7 @@ const PaymentSetting = () => {
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers/index.js';
import { API, showError, toBoolean } from '../../helpers/index.js';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
@@ -28,7 +28,7 @@ const RateLimitSetting = () => {
}
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -8,7 +8,7 @@ import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVi
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const RatioSetting = () => {
const { t } = useTranslation();
@@ -51,7 +51,7 @@ const RatioSetting = () => {
}
}
if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -17,10 +17,13 @@ import {
removeTrailingSlash,
showError,
showSuccess,
toBoolean,
} from '../../helpers';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
@@ -57,13 +60,13 @@ const SystemSetting = () => {
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
EmailDomainWhitelist: [],
// telegram login
TelegramOAuthEnabled: '',
TelegramBotToken: '',
TelegramBotName: '',
LinuxDOOAuthEnabled: '',
LinuxDOClientId: '',
LinuxDOClientSecret: '',
ServerAddress: '',
});
const [originInputs, setOriginInputs] = useState({});
@@ -104,7 +107,7 @@ const SystemSetting = () => {
case 'LinuxDOOAuthEnabled':
case 'oidc.enabled':
case 'WorkerAllowHttpImageRequestEnabled':
item.value = item.value === 'true';
item.value = toBoolean(item.value);
break;
case 'Price':
case 'MinTopUp':
@@ -173,7 +176,7 @@ const SystemSetting = () => {
});
}
showSuccess('更新成功');
showSuccess(t('更新成功'));
// 更新本地状态
const newInputs = { ...inputs };
options.forEach((opt) => {
@@ -181,7 +184,7 @@ const SystemSetting = () => {
});
setInputs(newInputs);
} catch (error) {
showError('更新失败');
showError(t('更新失败'));
}
setLoading(false);
};
@@ -205,6 +208,11 @@ const SystemSetting = () => {
await updateOptions(options);
};
const submitServerAddress = async () => {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]);
};
const submitSMTP = async () => {
const options = [];
@@ -244,7 +252,7 @@ const SystemSetting = () => {
},
]);
} else {
showError('邮箱域名白名单格式不正确');
showError(t('邮箱域名白名单格式不正确'));
}
};
@@ -256,19 +264,19 @@ const SystemSetting = () => {
const domainRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
if (!domainRegex.test(domain)) {
showError('邮箱域名格式不正确,请输入有效的域名,如 gmail.com');
showError(t('邮箱域名格式不正确,请输入有效的域名,如 gmail.com'));
return;
}
// 检查是否已存在
if (emailDomainWhitelist.includes(domain)) {
showError('该域名已存在于白名单中');
showError(t('该域名已存在于白名单中'));
return;
}
setEmailDomainWhitelist([...emailDomainWhitelist, domain]);
setEmailToAdd('');
showSuccess('已添加到白名单');
showSuccess(t('已添加到白名单'));
}
};
@@ -332,7 +340,7 @@ const SystemSetting = () => {
!inputs['oidc.well_known'].startsWith('http://') &&
!inputs['oidc.well_known'].startsWith('https://')
) {
showError('Well-Known URL 必须以 http:// 或 https:// 开头');
showError(t('Well-Known URL 必须以 http:// 或 https:// 开头'));
return;
}
try {
@@ -341,11 +349,11 @@ const SystemSetting = () => {
res.data['authorization_endpoint'];
inputs['oidc.token_endpoint'] = res.data['token_endpoint'];
inputs['oidc.user_info_endpoint'] = res.data['userinfo_endpoint'];
showSuccess('获取 OIDC 配置成功!');
showSuccess(t('获取 OIDC 配置成功!'));
} catch (err) {
console.error(err);
showError(
'获取 OIDC 配置失败,请检查网络状况和 Well-Known URL 是否正确',
t('获取 OIDC 配置失败,请检查网络状况和 Well-Known URL 是否正确'),
);
return;
}
@@ -487,7 +495,25 @@ const SystemSetting = () => {
}}
>
<Card>
<Form.Section text='代理设置'>
<Form.Section text={t('通用设置')}>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Input
field='ServerAddress'
label={t('服务器地址')}
placeholder='https://yourdomain.com'
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
/>
</Col>
</Row>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('代理设置')}>
<Text>
支持{' '}
<a
@@ -505,14 +531,14 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WorkerUrl'
label='Worker地址'
label={t('Worker地址')}
placeholder='例如https://workername.yourdomain.workers.dev'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WorkerValidKey'
label='Worker密钥'
label={t('Worker密钥')}
placeholder='敏感信息不会发送到前端显示'
type='password'
/>
@@ -522,14 +548,14 @@ const SystemSetting = () => {
field='WorkerAllowHttpImageRequestEnabled'
noLabel
>
允许 HTTP 协议图片请求适用于自部署代理
{t('允许 HTTP 协议图片请求适用于自部署代理)')}
</Form.Checkbox>
<Button onClick={submitWorker}>更新Worker设置</Button>
<Button onClick={submitWorker}>{t('更新Worker设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置登录注册'>
<Form.Section text={t('配置登录注册')}>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
@@ -541,7 +567,7 @@ const SystemSetting = () => {
handleCheckboxChange('PasswordLoginEnabled', e)
}
>
允许通过密码进行登录
{t('允许通过密码进行登录')}
</Form.Checkbox>
<Form.Checkbox
field='PasswordRegisterEnabled'
@@ -550,7 +576,7 @@ const SystemSetting = () => {
handleCheckboxChange('PasswordRegisterEnabled', e)
}
>
允许通过密码进行注册
{t('允许通过密码进行注册')}
</Form.Checkbox>
<Form.Checkbox
field='EmailVerificationEnabled'
@@ -559,7 +585,7 @@ const SystemSetting = () => {
handleCheckboxChange('EmailVerificationEnabled', e)
}
>
通过密码注册时需要进行邮箱验证
{t('通过密码注册时需要进行邮箱验证')}
</Form.Checkbox>
<Form.Checkbox
field='RegisterEnabled'
@@ -568,7 +594,7 @@ const SystemSetting = () => {
handleCheckboxChange('RegisterEnabled', e)
}
>
允许新用户注册
{t('允许新用户注册')}
</Form.Checkbox>
<Form.Checkbox
field='TurnstileCheckEnabled'
@@ -577,7 +603,7 @@ const SystemSetting = () => {
handleCheckboxChange('TurnstileCheckEnabled', e)
}
>
启用 Turnstile 用户校验
{t('允许 Turnstile 用户校验')}
</Form.Checkbox>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
@@ -588,7 +614,7 @@ const SystemSetting = () => {
handleCheckboxChange('GitHubOAuthEnabled', e)
}
>
允许通过 GitHub 账户登录 & 注册
{t('允许通过 GitHub 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='LinuxDOOAuthEnabled'
@@ -597,7 +623,7 @@ const SystemSetting = () => {
handleCheckboxChange('LinuxDOOAuthEnabled', e)
}
>
允许通过 Linux DO 账户登录 & 注册
{t('允许通过 Linux DO 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='WeChatAuthEnabled'
@@ -606,7 +632,7 @@ const SystemSetting = () => {
handleCheckboxChange('WeChatAuthEnabled', e)
}
>
允许通过微信登录 & 注册
{t('允许通过微信登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='TelegramOAuthEnabled'
@@ -615,7 +641,7 @@ const SystemSetting = () => {
handleCheckboxChange('TelegramOAuthEnabled', e)
}
>
允许通过 Telegram 进行登录
{t('允许通过 Telegram 进行登录')}
</Form.Checkbox>
<Form.Checkbox
field="['oidc.enabled']"
@@ -624,7 +650,7 @@ const SystemSetting = () => {
handleCheckboxChange('oidc.enabled', e)
}
>
允许通过 OIDC 进行登录
{t('允许通过 OIDC 进行登录')}
</Form.Checkbox>
</Col>
</Row>
@@ -632,8 +658,8 @@ const SystemSetting = () => {
</Card>
<Card>
<Form.Section text='配置邮箱域名白名单'>
<Text>用以防止恶意用户利用临时邮箱批量注册</Text>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
@@ -669,11 +695,11 @@ const SystemSetting = () => {
<TagInput
value={emailDomainWhitelist}
onChange={setEmailDomainWhitelist}
placeholder='输入域名后回车'
placeholder={t('输入域名后回车')}
style={{ width: '100%', marginTop: 16 }}
/>
<Form.Input
placeholder='输入要添加的邮箱域名'
placeholder={t('输入要添加的邮箱域名')}
value={emailToAdd}
onChange={(value) => setEmailToAdd(value)}
style={{ marginTop: 16 }}
@@ -683,7 +709,7 @@ const SystemSetting = () => {
type='primary'
onClick={handleAddEmail}
>
添加
{t('添加')}
</Button>
}
onEnterPress={handleAddEmail}
@@ -692,24 +718,24 @@ const SystemSetting = () => {
onClick={submitEmailDomainWhitelist}
style={{ marginTop: 10 }}
>
保存邮箱域名白名单设置
{t('保存邮箱域名白名单设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 SMTP'>
<Text>用以支持系统的邮件发送</Text>
<Form.Section text={t('配置 SMTP')}>
<Text>{t('用以支持系统的邮件发送')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPServer' label='SMTP 服务器地址' />
<Form.Input field='SMTPServer' label={t('SMTP 服务器地址')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPPort' label='SMTP 端口' />
<Form.Input field='SMTPPort' label={t('SMTP 端口')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPAccount' label='SMTP 账户' />
<Form.Input field='SMTPAccount' label={t('SMTP 账户')} />
</Col>
</Row>
<Row
@@ -717,12 +743,12 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPFrom' label='SMTP 发送者邮箱' />
<Form.Input field='SMTPFrom' label={t('SMTP 发送者邮箱')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMTPToken'
label='SMTP 访问凭证'
label={t('SMTP 访问凭证')}
type='password'
placeholder='敏感信息不会发送到前端显示'
/>
@@ -735,27 +761,25 @@ const SystemSetting = () => {
handleCheckboxChange('SMTPSSLEnabled', e)
}
>
启用SMTP SSL
{t('启用SMTP SSL')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>保存 SMTP 设置</Button>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 OIDC'>
<Form.Section text={t('配置 OIDC')}>
<Text>
用以支持通过 OIDC 登录例如 OktaAuth0 等兼容 OIDC 协议的
IdP
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')}
</Text>
<Banner
type='info'
description={`主页链接填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}重定向 URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/oidc`}
description={`${t('主页链接填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}${t('重定向 URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/oidc`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Text>
若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写
OIDC Well-Known URL系统会自动获取 OIDC 配置
{t('若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写 OIDC Well-Known URL系统会自动获取 OIDC 配置')}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
@@ -763,15 +787,15 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.well_known']"
label='Well-Known URL'
placeholder='请输入 OIDC 的 Well-Known URL'
label={t('Well-Known URL')}
placeholder={t('请输入 OIDC 的 Well-Known URL')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.client_id']"
label='Client ID'
placeholder='输入 OIDC 的 Client ID'
label={t('Client ID')}
placeholder={t('输入 OIDC 的 Client ID')}
/>
</Col>
</Row>
@@ -781,16 +805,16 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.client_secret']"
label='Client Secret'
label={t('Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.authorization_endpoint']"
label='Authorization Endpoint'
placeholder='输入 OIDC 的 Authorization Endpoint'
label={t('Authorization Endpoint')}
placeholder={t('输入 OIDC 的 Authorization Endpoint')}
/>
</Col>
</Row>
@@ -800,28 +824,28 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.token_endpoint']"
label='Token Endpoint'
placeholder='输入 OIDC 的 Token Endpoint'
label={t('Token Endpoint')}
placeholder={t('输入 OIDC 的 Token Endpoint')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.user_info_endpoint']"
label='User Info Endpoint'
placeholder='输入 OIDC 的 Userinfo Endpoint'
label={t('User Info Endpoint')}
placeholder={t('输入 OIDC 的 Userinfo Endpoint')}
/>
</Col>
</Row>
<Button onClick={submitOIDCSettings}>保存 OIDC 设置</Button>
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 GitHub OAuth App'>
<Text>用以支持通过 GitHub 进行登录注册</Text>
<Form.Section text={t('配置 GitHub OAuth App')}>
<Text>{t('用以支持通过 GitHub 进行登录注册')}</Text>
<Banner
type='info'
description={`Homepage URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}Authorization callback URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/github`}
description={`${t('Homepage URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}${t('Authorization callback URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/github`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
@@ -830,27 +854,27 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='GitHubClientId'
label='GitHub Client ID'
label={t('GitHub Client ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='GitHubClientSecret'
label='GitHub Client Secret'
label={t('GitHub Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitGitHubOAuth}>
保存 GitHub OAuth 设置
{t('保存 GitHub OAuth 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Linux DO OAuth'>
<Form.Section text={t('配置 Linux DO OAuth')}>
<Text>
用以支持通过 Linux DO 进行登录注册
{t('用以支持通过 Linux DO 进行登录注册')}
<a
href='https://connect.linux.do/'
target='_blank'
@@ -861,13 +885,13 @@ const SystemSetting = () => {
marginRight: 4,
}}
>
点击此处
{t('点击此处')}
</a>
管理你的 LinuxDO OAuth App
{t('管理你的 LinuxDO OAuth App')}
</Text>
<Banner
type='info'
description={`回调 URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/linuxdo`}
description={`${t('回调 URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/linuxdo`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
@@ -876,122 +900,122 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='LinuxDOClientId'
label='Linux DO Client ID'
placeholder='输入你注册的 LinuxDO OAuth APP 的 ID'
label={t('Linux DO Client ID')}
placeholder={t('输入你注册的 LinuxDO OAuth APP 的 ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='LinuxDOClientSecret'
label='Linux DO Client Secret'
label={t('Linux DO Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitLinuxDOOAuth}>
保存 Linux DO OAuth 设置
{t('保存 Linux DO OAuth 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 WeChat Server'>
<Text>用以支持通过微信进行登录注册</Text>
<Form.Section text={t('配置 WeChat Server')}>
<Text>{t('用以支持通过微信进行登录注册')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatServerAddress'
label='WeChat Server 服务器地址'
label={t('WeChat Server 服务器地址')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatServerToken'
label='WeChat Server 访问凭证'
label={t('WeChat Server 访问凭证')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatAccountQRCodeImageURL'
label='微信公众号二维码图片链接'
label={t('微信公众号二维码图片链接')}
/>
</Col>
</Row>
<Button onClick={submitWeChat}>
保存 WeChat Server 设置
{t('保存 WeChat Server 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Telegram 登录'>
<Text>用以支持通过 Telegram 进行登录注册</Text>
<Form.Section text={t('配置 Telegram 登录')}>
<Text>{t('用以支持通过 Telegram 进行登录注册')}</Text>
<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}>
<Form.Input
field='TelegramBotToken'
label='Telegram Bot Token'
placeholder='敏感信息不会发送到前端显示'
label={t('Telegram Bot Token')}
placeholder={t('敏感信息不会发送到前端显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TelegramBotName'
label='Telegram Bot 名称'
label={t('Telegram Bot 名称')}
/>
</Col>
</Row>
<Button onClick={submitTelegramSettings}>
保存 Telegram 登录设置
{t('保存 Telegram 登录设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Turnstile'>
<Text>用以支持用户校验</Text>
<Form.Section text={t('配置 Turnstile')}>
<Text>{t('用以支持用户校验')}</Text>
<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}>
<Form.Input
field='TurnstileSiteKey'
label='Turnstile Site Key'
label={t('Turnstile Site Key')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TurnstileSecretKey'
label='Turnstile Secret Key'
label={t('Turnstile Secret Key')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitTurnstile}>保存 Turnstile 设置</Button>
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button>
</Form.Section>
</Card>
<Modal
title='确认取消密码登录'
title={t('确认取消密码登录')}
visible={showPasswordLoginConfirmModal}
onOk={handlePasswordLoginConfirm}
onCancel={() => {
setShowPasswordLoginConfirmModal(false);
formApiRef.current.setValue('PasswordLoginEnabled', true);
}}
okText='确认'
cancelText='取消'
okText={t('确认')}
cancelText={t('取消')}
>
<p>您确定要取消密码登录功能吗这可能会影响用户的登录方式</p>
<p>{t('您确定要取消密码登录功能吗这可能会影响用户的登录方式。')}</p>
</Modal>
</div>
)}

View File

@@ -42,18 +42,22 @@ import {
IconTreeTriangleDown,
IconSearch,
IconMore,
IconDescend2
} from '@douyinfe/semi-icons';
import { loadChannelModels, isMobile, copy } from '../../helpers';
import { loadChannelModels, copy } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
import { FaRandom } from 'react-icons/fa';
const ChannelsTable = () => {
const { t } = useTranslation();
const isMobile = useIsMobile();
let type2label = undefined;
const renderType = (type) => {
const renderType = (type, channelInfo = undefined) => {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
@@ -61,12 +65,30 @@ const ChannelsTable = () => {
}
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
}
let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) {
icon = (
channelInfo?.multi_key_mode === 'random' ? (
<div className="flex items-center gap-1">
<FaRandom className="text-blue-500" />
{icon}
</div>
) : (
<div className="flex items-center gap-1">
<IconDescend2 className="text-blue-500" />
{icon}
</div>
)
)
}
return (
<Tag
size='large'
color={type2label[type]?.color}
shape='circle'
prefixIcon={getChannelIcon(type)}
prefixIcon={icon}
>
{type2label[type]?.label}
</Tag>
@@ -77,7 +99,6 @@ const ChannelsTable = () => {
return (
<Tag
color='light-blue'
size='large'
shape='circle'
type='light'
>
@@ -86,65 +107,107 @@ const ChannelsTable = () => {
);
};
const renderStatus = (status) => {
const renderStatus = (status, channelInfo = undefined) => {
if (channelInfo) {
if (channelInfo.is_multi_key) {
let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) {
// multi_key_status_list is a map, key is key, value is status
// get multi_key_status_list length
enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
}
return renderMultiKeyStatus(status, keySize, enabledKeySize);
}
}
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle'>
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='red' shape='circle'>
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const renderMultiKeyStatus = (status, keySize, enabledKeySize) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')} {enabledKeySize}/{keySize}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')} {enabledKeySize}/{keySize}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')} {enabledKeySize}/{keySize}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')} {enabledKeySize}/{keySize}
</Tag>
);
}
}
const renderResponseTime = (responseTime) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size='large' color='green' shape='circle'>
<Tag color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size='large' color='lime' shape='circle'>
<Tag color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag size='large' color='red' shape='circle'>
<Tag color='red' shape='circle'>
{time}
</Tag>
);
@@ -281,6 +344,11 @@ const ChannelsTable = () => {
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
if (record.channel_info) {
if (record.channel_info.is_multi_key) {
return <>{renderType(text, record.channel_info)}</>;
}
}
return <>{renderType(text)}</>;
} else {
return <>{renderTagType()}</>;
@@ -304,12 +372,12 @@ const ChannelsTable = () => {
<Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
>
{renderStatus(text)}
{renderStatus(text, record.channel_info)}
</Tooltip>
</div>
);
} else {
return renderStatus(text);
return renderStatus(text, record.channel_info);
}
},
},
@@ -331,7 +399,7 @@ const ChannelsTable = () => {
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -339,7 +407,6 @@ const ChannelsTable = () => {
<Tag
color='white'
type='ghost'
size='large'
shape='circle'
onClick={() => updateChannelBalance(record)}
>
@@ -352,7 +419,7 @@ const ChannelsTable = () => {
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -482,9 +549,15 @@ const ChannelsTable = () => {
title: t('确定是否要删除此渠道?'),
content: t('此修改将不可逆'),
onOk: () => {
manageChannel(record.id, 'delete', record).then(() => {
removeRecord(record);
});
(async () => {
await manageChannel(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
@@ -492,7 +565,7 @@ const ChannelsTable = () => {
{
node: 'item',
name: t('复制'),
type: 'primary',
type: 'tertiary',
onClick: () => {
Modal.confirm({
title: t('确定是否要复制此渠道?'),
@@ -510,15 +583,15 @@ const ChannelsTable = () => {
aria-label={t('测试单个渠道操作项目组')}
>
<Button
theme='light'
size="small"
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
theme='light'
size="small"
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
setCurrentTestChannel(record);
@@ -527,28 +600,66 @@ const ChannelsTable = () => {
/>
</SplitButtonGroup>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup
aria-label={t('多密钥渠道操作项目组')}
>
{t('禁用')}
</Button>
{
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
}
<Dropdown
trigger='click'
position='bottomRight'
menu={[
{
node: 'item',
name: t('启用全部密钥'),
onClick: () => manageChannel(record.id, 'enable_all', record),
}
]}
>
<Button
type='tertiary'
size="small"
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
</SplitButtonGroup>
) : (
<Button
theme='light'
type='secondary'
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('用')}
</Button>
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
)}
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -566,7 +677,6 @@ const ChannelsTable = () => {
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
/>
@@ -578,23 +688,20 @@ const ChannelsTable = () => {
return (
<Space wrap>
<Button
theme='light'
type='secondary'
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
theme='light'
type='warning'
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -666,22 +773,13 @@ const ChannelsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -868,42 +966,29 @@ const ChannelsTable = () => {
};
const copySelectedChannel = async (record) => {
const channelToCopy = { ...record };
channelToCopy.name += t('_复制');
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
delete channelToCopy.test_time;
delete channelToCopy.response_time;
if (!channelToCopy) {
showError(t('渠道未找到,请刷新页面后重试。'));
return;
}
try {
const newChannel = { ...channelToCopy, id: undefined };
const response = await API.post('/api/channel/', newChannel);
if (response.data.success) {
const res = await API.post(`/api/channel/copy/${record.id}`);
if (res?.data?.success) {
showSuccess(t('渠道复制成功'));
await refresh();
} else {
showError(response.data.message);
showError(res?.data?.message || t('渠道复制失败'));
}
} catch (error) {
showError(t('渠道复制失败: ') + error.message);
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
}
};
const refresh = async () => {
const refresh = async (page = activePage) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage, pageSize, idSort, enableTagMode);
await loadChannels(page, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
useEffect(() => {
// console.log('default effect')
const localIdSort = localStorage.getItem('id-sort') === 'true';
const localPageSize =
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
@@ -954,6 +1039,11 @@ const ChannelsTable = () => {
}
res = await API.put('/api/channel/', data);
break;
case 'enable_all':
data.channel_info = record.channel_info;
data.channel_info.multi_key_status_list = {};
res = await API.put('/api/channel/', data);
break;
}
const { success, message } = res.data;
if (success) {
@@ -1240,7 +1330,7 @@ const ChannelsTable = () => {
tab={
<span className="flex items-center gap-2">
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} size='small' shape='circle'>
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
@@ -1258,7 +1348,7 @@ const ChannelsTable = () => {
<span className="flex items-center gap-2">
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} size='small' shape='circle'>
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
{count}
</Tag>
</span>
@@ -1453,6 +1543,11 @@ const ChannelsTable = () => {
if (success) {
showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
} else {
showError(message);
}
@@ -1461,7 +1556,7 @@ const ChannelsTable = () => {
const fixChannelsAbilities = async () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
await refresh();
@@ -1478,7 +1573,6 @@ const ChannelsTable = () => {
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='danger'
className="w-full md:w-auto"
onClick={() => {
@@ -1495,8 +1589,7 @@ const ChannelsTable = () => {
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='primary'
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto"
>
@@ -1511,8 +1604,7 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
type='warning'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
@@ -1530,7 +1622,23 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='secondary'
className="w-full"
onClick={() => {
@@ -1549,7 +1657,6 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
type='danger'
className="w-full"
onClick={() => {
@@ -1565,25 +1672,6 @@ const ChannelsTable = () => {
{t('删除禁用通道')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
</Dropdown.Menu>
}
>
@@ -1594,8 +1682,7 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
@@ -1698,8 +1785,7 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='primary'
type='tertiary'
className="w-full md:w-auto"
onClick={refresh}
>
@@ -1708,7 +1794,6 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto"
@@ -1771,7 +1856,7 @@ const ChannelsTable = () => {
</div>
<Button
size='small'
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="w-full md:w-auto"
@@ -1780,7 +1865,7 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -1885,7 +1970,6 @@ const ChannelsTable = () => {
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
size='large'
/>
<div className="mt-4">
<Typography.Text type='secondary'>
@@ -1907,61 +1991,6 @@ const ChannelsTable = () => {
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button
theme='light'
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
</div>
)
}
@@ -1971,15 +2000,13 @@ const ChannelsTable = () => {
<div className="flex justify-end">
{isBatchTesting ? (
<Button
theme='light'
type='warning'
type='danger'
onClick={handleCloseModal}
>
{t('停止测试')}
</Button>
) : (
<Button
theme='light'
type='tertiary'
onClick={handleCloseModal}
>
@@ -1987,8 +2014,6 @@ const ChannelsTable = () => {
</Button>
)}
<Button
theme='light'
type='primary'
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
@@ -2008,11 +2033,63 @@ const ChannelsTable = () => {
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size={isMobile() ? 'full-width' : 'large'}
size={isMobile ? 'full-width' : 'large'}
>
<div className="model-test-scroll">
{currentTestChannel && (
<div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
<Table
columns={[
{
@@ -2033,7 +2110,7 @@ const ChannelsTable = () => {
if (isTesting) {
return (
<Tag size='large' color='blue' shape='circle'>
<Tag color='blue' shape='circle'>
{t('测试中')}
</Tag>
);
@@ -2041,7 +2118,7 @@ const ChannelsTable = () => {
if (!testResult) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未开始')}
</Tag>
);
@@ -2050,7 +2127,6 @@ const ChannelsTable = () => {
return (
<div className="flex items-center gap-2">
<Tag
size='large'
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
@@ -2072,8 +2148,7 @@ const ChannelsTable = () => {
const isTesting = testingModels.has(record.model);
return (
<Button
theme='light'
type='primary'
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
loading={isTesting}
size='small'

View File

@@ -20,7 +20,7 @@ import {
renderQuota,
stringToColor,
getLogOther,
renderModelTag,
renderModelTag
} from '../../helpers';
import {
@@ -78,37 +78,37 @@ const LogsTable = () => {
switch (type) {
case 1:
return (
<Tag color='cyan' size='large' shape='circle'>
<Tag color='cyan' shape='circle'>
{t('充值')}
</Tag>
);
case 2:
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' shape='circle'>
{t('消费')}
</Tag>
);
case 3:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{t('管理')}
</Tag>
);
case 4:
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' shape='circle'>
{t('系统')}
</Tag>
);
case 5:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{t('错误')}
</Tag>
);
default:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未知')}
</Tag>
);
@@ -118,13 +118,13 @@ const LogsTable = () => {
function renderIsStream(bool) {
if (bool) {
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' shape='circle'>
{t('流')}
</Tag>
);
} else {
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' shape='circle'>
{t('非流')}
</Tag>
);
@@ -135,21 +135,21 @@ const LogsTable = () => {
const time = parseInt(type);
if (time < 101) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 300) {
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -162,21 +162,21 @@ const LogsTable = () => {
time = time.toFixed(1);
if (time < 3) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 10) {
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -356,28 +356,34 @@ const LogsTable = () => {
dataIndex: 'channel',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
record.type === 0 || record.type === 2 || record.type === 5 ? (
<div>
{
<Tooltip content={record.channel_name || '[未知]'}>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
>
{' '}
{text}{' '}
</Tag>
</Tooltip>
}
</div>
) : (
<></>
)
) : (
<></>
);
let isMultiKey = false
let multiKeyIndex = -1;
let other = getLogOther(record.other);
if (other?.admin_info) {
let adminInfo = other.admin_info;
if (adminInfo?.is_multi_key) {
isMultiKey = true;
multiKeyIndex = adminInfo.multi_key_index;
}
}
return isAdminUser && (record.type === 0 || record.type === 2 || record.type === 5) ? (
<Space>
<Tooltip content={record.channel_name || t('未知渠道')}>
<Tag
color={colors[parseInt(text) % colors.length]}
shape='circle'
>
{text}
</Tag>
</Tooltip>
{isMultiKey && (
<Tag color='white' shape='circle'>
{multiKeyIndex}
</Tag>
)}
</Space>
) : null;
},
},
{
@@ -389,7 +395,7 @@ const LogsTable = () => {
return isAdminUser ? (
<div>
<Avatar
size='small'
size='extra-small'
color={stringToColor(text)}
style={{ marginRight: 4 }}
onClick={(event) => {
@@ -415,7 +421,6 @@ const LogsTable = () => {
<div>
<Tag
color='grey'
size='large'
shape='circle'
onClick={(event) => {
//cancel the row click event
@@ -567,7 +572,6 @@ const LogsTable = () => {
<Tooltip content={text}>
<Tag
color='orange'
size='large'
shape='circle'
onClick={(event) => {
copyText(event, text);
@@ -693,22 +697,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className='flex justify-end'>
<Button
theme='light'
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme='light'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -1215,11 +1210,10 @@ const LogsTable = () => {
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: 13,
}}
className='!rounded-lg'
>
@@ -1227,11 +1221,10 @@ const LogsTable = () => {
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: 13,
}}
className='!rounded-lg'
>
@@ -1239,12 +1232,11 @@ const LogsTable = () => {
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
fontWeight: 500,
padding: 13,
}}
className='!rounded-lg'
>
@@ -1253,10 +1245,10 @@ const LogsTable = () => {
</Space>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -1287,6 +1279,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -1297,6 +1290,7 @@ const LogsTable = () => {
placeholder={t('令牌名称')}
showClear
pure
size="small"
/>
<Form.Input
@@ -1305,6 +1299,7 @@ const LogsTable = () => {
placeholder={t('模型名称')}
showClear
pure
size="small"
/>
<Form.Input
@@ -1313,6 +1308,7 @@ const LogsTable = () => {
placeholder={t('分组')}
showClear
pure
size="small"
/>
{isAdminUser && (
@@ -1323,6 +1319,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
<Form.Input
field='username'
@@ -1330,6 +1327,7 @@ const LogsTable = () => {
placeholder={t('用户名称')}
showClear
pure
size="small"
/>
</>
)}
@@ -1351,6 +1349,7 @@ const LogsTable = () => {
refresh();
}, 0);
}}
size="small"
>
<Form.Select.Option value='0'>
{t('全部')}
@@ -1375,14 +1374,15 @@ const LogsTable = () => {
<div className='flex gap-2 w-full sm:w-auto justify-end'>
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -1392,13 +1392,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -185,115 +185,115 @@ const LogsTable = () => {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')}
</Tag>
);
case 'VIDEO':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('视频')}
</Tag>
);
case 'EDITS':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('编辑')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
<Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
<Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
<Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
<Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
<Tag color='violet' shape='circle' prefixIcon={<PaintBucket size={14} />}>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
<Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
<Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
<Tag color='light-green' shape='circle' prefixIcon={<UserCheck size={14} />}>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -304,31 +304,31 @@ const LogsTable = () => {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -339,43 +339,43 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -405,7 +405,7 @@ const LogsTable = () => {
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')}
</Tag>
);
@@ -439,7 +439,6 @@ const LogsTable = () => {
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
@@ -523,6 +522,7 @@ const LogsTable = () => {
}
return (
<Button
size="small"
onClick={() => {
setModalImageUrl(text);
setIsModalOpenurl(true);
@@ -741,22 +741,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -831,10 +822,10 @@ const LogsTable = () => {
)}
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -864,6 +855,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -874,6 +866,7 @@ const LogsTable = () => {
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
@@ -884,6 +877,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
)}
</div>
@@ -893,14 +887,15 @@ const LogsTable = () => {
<div></div>
<div className="flex gap-2">
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -910,13 +905,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -16,7 +16,9 @@ import {
Card,
Tabs,
TabPane,
Empty
Empty,
Switch,
Select
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -32,6 +34,7 @@ import {
} from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
import { StatusContext } from '../../context/Status/index.js';
const ModelPricing = () => {
const { t } = useTranslation();
@@ -44,6 +47,14 @@ const ModelPricing = () => {
const [activeKey, setActiveKey] = useState('all');
const [pageSize, setPageSize] = useState(10);
const [currency, setCurrency] = useState('USD');
const [showWithRecharge, setShowWithRecharge] = useState(false);
const [tokenUnit, setTokenUnit] = useState('M');
const [statusState] = useContext(StatusContext);
// 充值汇率price与美元兑人民币汇率usd_exchange_rate
const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]);
const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]);
const rowSelection = useMemo(
() => ({
onChange: (selectedRowKeys, selectedRows) => {
@@ -76,13 +87,13 @@ const ModelPricing = () => {
switch (type) {
case 1:
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' shape='circle'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large' shape='circle'>
<Tag color='violet' shape='circle'>
{t('按量计费')}
</Tag>
);
@@ -116,7 +127,6 @@ const ModelPricing = () => {
<Tag
key={endpoint}
color={stringToColor(endpoint)}
size='large'
shape='circle'
>
{endpoint}
@@ -126,6 +136,18 @@ const ModelPricing = () => {
);
}
const displayPrice = (usdPrice) => {
let priceInUSD = usdPrice;
if (showWithRecharge) {
priceInUSD = usdPrice * priceRate / usdExchangeRate;
}
if (currency === 'CNY') {
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
}
return `$${priceInUSD.toFixed(3)}`;
};
const columns = [
{
title: t('可用性'),
@@ -179,7 +201,7 @@ const ModelPricing = () => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
<Tag color='blue' shape='circle' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
@@ -187,7 +209,6 @@ const ModelPricing = () => {
return (
<Tag
color='blue'
size='large'
shape='circle'
onClick={() => {
setSelectedGroup(group);
@@ -247,33 +268,54 @@ const ModelPricing = () => {
},
},
{
title: t('模型价格'),
title: (
<div className="flex items-center space-x-2">
<span>{t('模型价格')}</span>
{/* 计费单位切换 */}
<Switch
checked={tokenUnit === 'K'}
onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
checkedText="K"
uncheckedText="M"
/>
</div>
),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
let inputRatioPrice =
record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPrice =
record.model_ratio *
record.completion_ratio *
2 *
groupRatio[selectedGroup];
let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
let displayInput = displayPrice(inputRatioPriceUSD);
let displayCompletion = displayPrice(completionRatioPriceUSD);
const divisor = unitDivisor;
const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
{t('提示')} {displayInput} / 1{unitLabel} tokens
</div>
<div className="text-gray-700">
{t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
</div>
</div>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
let displayVal = displayPrice(priceUSD);
content = (
<div className="text-gray-700">
{t('模型价格')}${price.toFixed(3)}
{t('模型价格')}{displayVal}
</div>
);
}
@@ -392,7 +434,6 @@ const ModelPricing = () => {
{category.label}
<Tag
color={activeKey === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
@@ -436,7 +477,6 @@ const ModelPricing = () => {
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
size="large"
/>
</div>
<Button
@@ -446,13 +486,33 @@ const ModelPricing = () => {
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!bg-blue-500 hover:!bg-blue-600 text-white"
size="large"
>
{t('复制选中模型')}
</Button>
{/* 充值价格显示开关 */}
<Space align="center">
<span>{t('以充值价格显示')}</span>
<Switch
checked={showWithRecharge}
onChange={setShowWithRecharge}
size="small"
/>
{showWithRecharge && (
<Select
value={currency}
onChange={setCurrency}
size="small"
style={{ width: 100 }}
>
<Select.Option value="USD">USD ($)</Select.Option>
<Select.Option value="CNY">CNY (¥)</Select.Option>
</Select>
)}
</Space>
</div>
</Card>
), [selectedRowKeys, t]);
), [selectedRowKeys, t, showWithRecharge, currency]);
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" bordered={false}>

View File

@@ -53,31 +53,31 @@ const RedemptionsTable = () => {
const renderStatus = (status, record) => {
if (isExpired(record)) {
return (
<Tag color='orange' size='large' shape='circle'>{t('已过期')}</Tag>
<Tag color='orange' shape='circle'>{t('已过期')}</Tag>
);
}
switch (status) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' shape='circle'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' shape='circle'>
{t('未知状态')}
</Tag>
);
@@ -107,7 +107,7 @@ const RedemptionsTable = () => {
render: (text, record, index) => {
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle'>
<Tag color='grey' shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
</div>
@@ -139,6 +139,7 @@ const RedemptionsTable = () => {
title: '',
dataIndex: 'operate',
fixed: 'right',
width: 205,
render: (text, record, index) => {
// 创建更多操作的下拉菜单项
const moreMenuItems = [
@@ -151,9 +152,15 @@ const RedemptionsTable = () => {
title: t('确定是否要删除此兑换码?'),
content: t('此修改将不可逆'),
onOk: () => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
(async () => {
await manageRedemption(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (redemptions.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
@@ -185,7 +192,6 @@ const RedemptionsTable = () => {
<Space>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button
theme='light'
type='tertiary'
size="small"
>
@@ -193,8 +199,6 @@ const RedemptionsTable = () => {
</Button>
</Popover>
<Button
theme='light'
type='secondary'
size="small"
onClick={async () => {
await copyText(record.key);
@@ -203,7 +207,6 @@ const RedemptionsTable = () => {
{t('复制')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -220,7 +223,6 @@ const RedemptionsTable = () => {
menu={moreMenuItems}
>
<Button
theme='light'
type='tertiary'
size="small"
icon={<IconMore />}
@@ -320,8 +322,13 @@ const RedemptionsTable = () => {
});
}, [pageSize]);
const refresh = async () => {
await loadRedemptions(activePage - 1, pageSize);
const refresh = async (page = activePage) => {
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
await loadRedemptions(page, pageSize);
} else {
await searchRedemptions(searchKeyword, page, pageSize);
}
};
const manageRedemption = async (id, action, record) => {
@@ -424,10 +431,10 @@ const RedemptionsTable = () => {
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -440,7 +447,6 @@ const RedemptionsTable = () => {
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex gap-2 w-full sm:w-auto">
<Button
theme='light'
type='primary'
className="w-full sm:w-auto"
onClick={() => {
@@ -449,11 +455,12 @@ const RedemptionsTable = () => {
});
setShowEdit(true);
}}
size="small"
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
type='tertiary'
className="w-full sm:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
@@ -467,6 +474,7 @@ const RedemptionsTable = () => {
}
await copyText(keys);
}}
size="small"
>
{t('复制所选兑换码到剪贴板')}
</Button>
@@ -492,6 +500,7 @@ const RedemptionsTable = () => {
},
});
}}
size="small"
>
{t('清除失效兑换码')}
</Button>
@@ -519,23 +528,24 @@ const RedemptionsTable = () => {
placeholder={t('关键字(id或者名称)')}
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
theme="light"
type="tertiary"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadRedemptions(1, pageSize);
@@ -543,6 +553,7 @@ const RedemptionsTable = () => {
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>

View File

@@ -34,7 +34,6 @@ import {
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography
@@ -106,7 +105,7 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
<Tag color={color} prefixIcon={<Clock size={14} />}>
{durationSec}
</Tag>
);
@@ -198,31 +197,31 @@ const LogsTable = () => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
<Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
case TASK_ACTION_GENERATE:
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('图生视频')}
</Tag>
);
case TASK_ACTION_TEXT_GENERATE:
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('文生视频')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -233,25 +232,25 @@ const LogsTable = () => {
switch (platform) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
Suno
</Tag>
);
case 'kling':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
Kling
</Tag>
);
case 'jimeng':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Video size={14} />}>
Jimeng
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -262,55 +261,55 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -596,22 +595,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -665,23 +655,13 @@ const LogsTable = () => {
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>{t('任务记录')}</Text>
)}
<Text>{t('任务记录')}</Text>
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -711,6 +691,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -721,6 +702,7 @@ const LogsTable = () => {
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
@@ -731,6 +713,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
)}
</div>
@@ -740,14 +723,15 @@ const LogsTable = () => {
<div></div>
<div className="flex gap-2">
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -757,13 +741,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -7,7 +7,7 @@ import {
timestamp2string,
renderGroup,
renderQuota,
getQuotaPerUnit
getModelCategories
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import {
@@ -22,6 +22,12 @@ import {
SplitButtonGroup,
Table,
Tag,
AvatarGroup,
Avatar,
Tooltip,
Progress,
Switch,
Input,
Typography
} from '@douyinfe/semi-ui';
import {
@@ -31,7 +37,9 @@ import {
import {
IconSearch,
IconTreeTriangleDown,
IconMore,
IconCopy,
IconEyeOpened,
IconEyeClosed,
} from '@douyinfe/semi-icons';
import { Key } from 'lucide-react';
import EditToken from '../../pages/Token/EditToken';
@@ -47,49 +55,6 @@ function renderTimestamp(timestamp) {
const TokensTable = () => {
const { t } = useTranslation();
const renderStatus = (status, model_limits_enabled = false) => {
switch (status) {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large' shape='circle' >
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large' shape='circle' >
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large' shape='circle' >
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large' shape='circle' >
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large' shape='circle' >
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle' >
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: t('名称'),
@@ -99,64 +64,253 @@ const TokensTable = () => {
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
return (
<div>
<Space>
{renderStatus(text, record.model_limits_enabled)}
{renderGroup(record.group)}
</Space>
</div>
);
},
},
{
title: t('已用额度'),
dataIndex: 'used_quota',
render: (text, record, index) => {
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' >
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
title: t('剩余额度'),
dataIndex: 'remain_quota',
render: (text, record, index) => {
const getQuotaColor = (quotaValue) => {
const quotaPerUnit = getQuotaPerUnit();
const dollarAmount = quotaValue / quotaPerUnit;
if (dollarAmount <= 0) {
return 'red';
} else if (dollarAmount <= 100) {
return 'yellow';
render: (text, record) => {
const enabled = text === 1;
const handleToggle = (checked) => {
if (checked) {
manageToken(record.id, 'enable', record);
} else {
return 'green';
manageToken(record.id, 'disable', record);
}
};
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle' >
{t('无限制')}
</Tag>
) : (
<Tag
size={'large'}
color={getQuotaColor(parseInt(text))}
shape='circle'
>
{renderQuota(parseInt(text))}
</Tag>
)}
let tagColor = 'black';
let tagText = t('未知状态');
if (enabled) {
tagColor = 'green';
tagText = t('已启用');
} else if (text === 2) {
tagColor = 'red';
tagText = t('已禁用');
} else if (text === 3) {
tagColor = 'yellow';
tagText = t('已过期');
} else if (text === 4) {
tagColor = 'grey';
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}
>
{tagText}
</Tag>
);
if (record.unlimited_quota) {
return content;
}
return (
<Tooltip
content={
<div className='text-xs'>
<div>{t('已用额度')}: {renderQuota(used)}</div>
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
<div>{t('总额度')}: {renderQuota(total)}</div>
</div>
}
>
{content}
</Tooltip>
);
},
},
{
title: t('分组'),
dataIndex: 'group',
key: 'group',
render: (text) => {
if (text === 'auto') {
return (
<Tooltip
content={t('当前分组为 auto会自动选择最优分组当一个组不可用时自动降级到下一个组熔断机制')}
position='top'
>
<Tag color='white' shape='circle'> {t('智能熔断')} </Tag>
</Tooltip>
);
}
return renderGroup(text);
},
},
{
title: t('密钥'),
key: 'token_key',
render: (text, record) => {
const fullKey = 'sk-' + record.key;
const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
const revealed = !!showKeys[record.id];
return (
<div className='w-[200px]'>
<Input
readOnly
value={revealed ? fullKey : maskedKey}
size='small'
suffix={
<div className='flex items-center'>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
aria-label='toggle token visibility'
onClick={(e) => {
e.stopPropagation();
setShowKeys(prev => ({ ...prev, [record.id]: !revealed }));
}}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyText(fullKey);
}}
/>
</div>
}
/>
</div>
);
},
},
{
title: t('可用模型'),
dataIndex: 'model_limits',
render: (text, record) => {
if (record.model_limits_enabled && text) {
const models = text.split(',').filter(Boolean);
const categories = getModelCategories(t);
const vendorAvatars = [];
const matchedModels = new Set();
Object.entries(categories).forEach(([key, category]) => {
if (key === 'all') return;
if (!category.icon || !category.filter) return;
const vendorModels = models.filter((m) => category.filter({ model_name: m }));
if (vendorModels.length > 0) {
vendorAvatars.push(
<Tooltip key={key} content={vendorModels.join(', ')} position='top' showArrow>
<Avatar size='extra-extra-small' alt={category.label} color='transparent'>
{category.icon}
</Avatar>
</Tooltip>
);
vendorModels.forEach((m) => matchedModels.add(m));
}
});
const unmatchedModels = models.filter((m) => !matchedModels.has(m));
if (unmatchedModels.length > 0) {
vendorAvatars.push(
<Tooltip key='unknown' content={unmatchedModels.join(', ')} position='top' showArrow>
<Avatar size='extra-extra-small' alt='unknown'>
{t('其他')}
</Avatar>
</Tooltip>
);
}
return (
<AvatarGroup size='extra-extra-small'>
{vendorAvatars}
</AvatarGroup>
);
} else {
return (
<Tag color='white' shape='circle'>
{t('无限制')}
</Tag>
);
}
},
},
{
title: t('IP限制'),
dataIndex: 'allow_ips',
render: (text) => {
if (!text || text.trim() === '') {
return (
<Tag color='white' shape='circle'>
{t('无限制')}
</Tag>
);
}
const ips = text
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean);
const displayIps = ips.slice(0, 1);
const extraCount = ips.length - displayIps.length;
const ipTags = displayIps.map((ip, idx) => (
<Tag key={idx} shape='circle'>
{ip}
</Tag>
));
if (extraCount > 0) {
ipTags.push(
<Tooltip
key='extra'
content={ips.slice(1).join(', ')}
position='top'
showArrow
>
<Tag shape='circle'>
{'+' + extraCount}
</Tag>
</Tooltip>
);
}
return <Space wrap>{ipTags}</Space>;
},
},
{
@@ -211,58 +365,6 @@ const TokensTable = () => {
}
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('查看'),
onClick: () => {
Modal.info({
title: t('令牌详情'),
content: 'sk-' + record.key,
size: 'large',
});
},
},
{
node: 'item',
name: t('删除'),
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
manageToken(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
type: 'warning',
onClick: () => {
manageToken(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
type: 'secondary',
onClick: () => {
manageToken(record.id, 'enable', record);
},
});
}
return (
<Space wrap>
<SplitButtonGroup
@@ -270,9 +372,8 @@ const TokensTable = () => {
aria-label={t('项目操作按钮组')}
>
<Button
theme='light'
size="small"
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
type='tertiary'
onClick={() => {
if (chatsArray.length === 0) {
showError(t('请联系管理员配置聊天链接'));
@@ -293,11 +394,7 @@ const TokensTable = () => {
menu={chatsArray}
>
<Button
style={{
padding: '4px 4px',
color: 'rgba(var(--semi-teal-7), 1)',
}}
type='primary'
type='tertiary'
icon={<IconTreeTriangleDown />}
size="small"
></Button>
@@ -305,18 +402,6 @@ const TokensTable = () => {
</SplitButtonGroup>
<Button
theme='light'
type='secondary'
size="small"
onClick={async (text) => {
await copyText('sk-' + record.key);
}}
>
{t('复制')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -327,18 +412,24 @@ const TokensTable = () => {
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
<Button
type='danger'
size="small"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
(async () => {
await manageToken(record.id, 'delete', record);
await refresh();
})();
},
});
}}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
/>
</Dropdown>
{t('删除')}
</Button>
</Space>
);
},
@@ -357,6 +448,7 @@ const TokensTable = () => {
id: undefined,
});
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
// Form 初始值
const formInitValues = {
@@ -405,8 +497,8 @@ const TokensTable = () => {
setLoading(false);
};
const refresh = async () => {
await loadTokens(1);
const refresh = async (page = activePage) => {
await loadTokens(page);
setSelectedKeys([]);
};
@@ -582,6 +674,11 @@ const TokensTable = () => {
const count = res.data.data || 0;
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
await refresh();
setTimeout(() => {
if (tokens.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
} else {
showError(res?.data?.message || t('删除失败'));
}
@@ -601,10 +698,10 @@ const TokensTable = () => {
<Text>{t('令牌用于API访问认证可以设置额度限制和模型权限。')}</Text>
</div>
<Button
theme="light"
type="secondary"
type="tertiary"
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -616,7 +713,6 @@ const TokensTable = () => {
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme="light"
type="primary"
className="flex-1 md:flex-initial"
onClick={() => {
@@ -625,12 +721,12 @@ const TokensTable = () => {
});
setShowEdit(true);
}}
size="small"
>
{t('添加令牌')}
</Button>
<Button
theme="light"
type="warning"
type='tertiary'
className="flex-1 md:flex-initial"
onClick={() => {
if (selectedKeys.length === 0) {
@@ -644,8 +740,7 @@ const TokensTable = () => {
footer: (
<Space>
<Button
type="primary"
theme="solid"
type='tertiary'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
@@ -659,7 +754,6 @@ const TokensTable = () => {
{t('名称+密钥')}
</Button>
<Button
theme="light"
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
@@ -675,12 +769,12 @@ const TokensTable = () => {
),
});
}}
size="small"
>
{t('复制所选令牌')}
</Button>
<Button
theme="light"
type="danger"
type='danger'
className="w-full md:w-auto"
onClick={() => {
if (selectedKeys.length === 0) {
@@ -697,6 +791,7 @@ const TokensTable = () => {
onOk: () => batchDeleteTokens(),
});
}}
size="small"
>
{t('删除所选令牌')}
</Button>
@@ -721,6 +816,7 @@ const TokensTable = () => {
placeholder={t('搜索关键字')}
showClear
pure
size="small"
/>
</div>
<div className="relative w-full md:w-56">
@@ -730,19 +826,21 @@ const TokensTable = () => {
placeholder={t('密钥')}
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
theme="light"
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -753,6 +851,7 @@ const TokensTable = () => {
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>

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