Compare commits

...

117 Commits

Author SHA1 Message Date
CaIon
3f53e4f53e 🔧 fix(model_ratio): adjust return values for gemini-2.5-pro and gemini-2.5-flash models 2025-06-24 18:08:42 +08:00
t0ng7u
0c5d4ca0a7 🎨 style(channels-table): standardize operation component size to small
All operation-related UI controls in `ChannelsTable` (buttons, dropdowns,
switches, inputs, tags, etc.) now explicitly use `size="small"`.

Reasons & benefits:
- Creates a more compact and consistent look across the table and modals.
- Improves visual coherence between desktop and mobile views.
- Purely presentational; no functional logic is affected.

No database changes or API interactions are involved.
2025-06-24 18:02:34 +08:00
t0ng7u
44495b153a 🚀 feat: enhance model testing UI with bulk selection, copy & success-filter buttons (#1288)
* ChannelsTable
  - Added row-level checkboxes to the model-testing table for multi-selection
  - Implemented cross-page “Select All / Deselect All” via rowSelection.onSelectAll
  - Introduced allSelectingRef to ignore redundant onChange after onSelectAll
  - Added “Copy Selected” button to copy chosen model names (comma-separated) using helpers.copy
  - Added “Select Successful” button to auto-tick all models that passed testing
  - Moved search bar and new action buttons into the modal title for better UX
  - Centralised page size constant MODEL_TABLE_PAGE_SIZE in channel.constants.js
  - Fixed pagination slicing and auto-page-switch logic during batch testing

* channel.constants
  - Exported MODEL_TABLE_PAGE_SIZE (default 10) for unified pagination control

This commit enables users to conveniently copy or filter successful models, fully supports cross-page bulk operations, and resolves previous selection inconsistencies.

Refs: #1288
2025-06-24 17:46:08 +08:00
t0ng7u
de6e551cdb fix: ensure table shows correct loading state on first render & during search
Frontend (`ChannelsTable.js`)
1. Initialize `loading` state to `true` so the spinner is visible while the first data request is in-flight.
2. Set `<Table>` prop `loading={loading || searching}` — the spinner now appears for both the initial load and any subsequent search requests.

Result
Users immediately see a loading indicator on page entry and whenever a search is running, improving perceived responsiveness.
2025-06-24 05:20:54 +08:00
t0ng7u
aeb393e391 🚀 feat: Align search API with channel listing & fix sorting toggle
1. Backend
   • `controller/channel.go`
     – Added pagination (`p`, `page_size`) support to `SearchChannels`.
     – Added independent `type` filter (keeps `type_counts` unaffected).
     – Returned `total`, `type_counts` to match `/api/channel/` response.

2. Frontend
   • `ChannelsTable.js`
     – `loadChannels` / `searchChannels` now pass `p`, `page_size`, `id_sort`, `type`, `status` correctly.
     – Pagination, page-size selector and type tabs work for both normal list and search mode.
     – Switch for “ID sort” calls proper API and keeps UI state in sync.
     – Removed unnecessary `normalize` helper; `getFormValues` back to concise form.

Result
• Search mode and normal listing now share identical pagination and filtering behavior.
• Type tabs show correct counts even after searching.
• “ID Sort” toggle no longer inverses actual behaviour.
2025-06-24 05:13:47 +08:00
t0ng7u
db1b11deaf fix(channels-table): preserve group filter when switching type or status tabs
Refactors `ChannelsTable.js` to ensure that the selected group filter is **never lost** when:

1. Cycling between channel-type tabs.
2. Changing the status dropdown (all / enabled / disabled).

Key points:

• `loadChannels` now detects active search filters (keyword / group / model) and transparently delegates to `searchChannels`, guaranteeing all parameters are sent in every request.
• `searchChannels` accepts optional `typeKey` and `statusF` arguments, enabling reuse without code duplication.
• Loading state handling is unified; no extra renders or side effects were introduced, keeping UI performance intact.
• Duplicate logic removed and responsibilities clearly separated for easier future maintenance.
2025-06-24 04:16:40 +08:00
t0ng7u
5a5e8ce652 Revert "🐛 fix: preserve group filter when switching channel type/status"
This reverts commit a8ba2eba33.
2025-06-24 01:51:26 +08:00
t0ng7u
6c31151430 💄 i18n: shorten channel search placeholder and update i18n
Replaced the verbose placeholder “Search channel ID, name, key and API address ...”
with a concise version “Channel ID, name, key, API address” in
`ChannelsTable.js` and synchronized the corresponding i18n entries.

This improves readability and keeps UI text consistent across languages.
2025-06-24 01:48:39 +08:00
t0ng7u
a8ba2eba33 🐛 fix: preserve group filter when switching channel type/status
Ensure that the selected "group" filter (and other form search values) persist across
type tab changes, status filter updates, pagination, and page-size changes.

Changes include:
• loadChannels: added `searchParams` argument and now appends keyword, group and model
  query strings to API calls.
• refresh / page handlers / type tabs / status Select: now pass current form values
  to loadChannels, keeping filters intact.
• searchChannels: maintains active type and status filters when issuing search requests.
• Form.Select (searchGroup): triggers loadChannels when only group filter is active,
  preventing parameter loss.
• Minor cleanup and comment adjustments.
2025-06-24 01:45:22 +08:00
t0ng7u
c974b1053c 🐛 fix(channel): remove duplicate model names in “Edit Channel” model dropdown (#1292)
• Unify the Select option structure as `{ key, label, value }`; add missing `key` to prevent duplicated rendering by Semi-UI.
• Trim and deduplicate the `models` array via `Set` inside `handleInputChange`, ensuring state always contains unique values.
• In the options-merging `useEffect`, use a `Map` keyed by `value` (after `trim`) to guarantee a unique `optionList` when combining backend data with currently selected models.
• Apply the same structure and de-duplication when:
  – Fetching models from `/api/channel/models`
  – Adding custom models (`addCustomModels`)
  – Fetching upstream model lists (`fetchUpstreamModelList`)
• Replace obsolete `text` field with `label` in custom option objects for consistency.

No backend changes are required; the fix is entirely front-end.

Closes #1292
2025-06-24 00:25:29 +08:00
t0ng7u
1ab75b8a92 🎨 feat(EditChannel): improve model selection UX, clipboard feedback & rounded styling (#1290)
* Added a dedicated effect to merge origin and selected models, ensuring selected items always remain in the dropdown list.
* Enhanced “Copy all models” button:
  * Shows info message when list is empty.
  * Displays success / error notification based on copy result.
* Unified UI look-and-feel by applying `!rounded-lg` class to inputs, selects, banners and buttons.
* i18n: added English translations for new prompts
  - "No models to copy"
  - "Model list copied to clipboard"
  - "Copy failed"
2025-06-24 00:02:22 +08:00
同語
75e3959474 🧬merge: Add a button to copy the selected model in the channel (#1290)
Merge pull request #1290 from JoeyLearnsToCode/feat-copy-models
2025-06-23 23:46:54 +08:00
t0ng7u
bc371778b6 🚀 feat: add enabled/disabled channel filtering & optimize type-based pagination (#1289)
WHAT’S NEW
• Backend
  – Introduced `parseStatusFilter` helper to normalize `status` query across handlers.
  – `GET /api/channel` & `GET /api/channel/search` now accept `status=enabled|disabled` to return only enabled or disabled channels.
  – Tag-mode branch respects both `statusFilter` and `typeFilter`; SQL paths trimmed to one query + one lightweight `GROUP BY` for `type_counts`.

• Frontend (`ChannelsTable.js`)
  – Added “Status Filter” `<Select>` (All / Enabled / Disabled) with localStorage persistence.
  – All data-loading and search requests now always append `type` (when not “all”) and `status` params, so filtering & pagination are handled entirely server-side.
  – Removed client-side post-filtering for type, preventing short pages and reducing CPU work.
  – Tabs’ type counts stay in sync via backend-provided `type_counts`.

IMPROVEMENTS
• Eliminated duplicated status-parsing logic; single source of truth eases future extension.
• Reduced redundant queries, improved consistency of counts in UI.
• Secured key leakage with `Omit("key")` unchanged; no perf regressions observed.

Closes #1289
2025-06-23 23:40:34 +08:00
t0ng7u
7c72545217 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-23 17:35:50 +08:00
t0ng7u
2591ca3d60 🚀 chore(ui): Refactor UpstreamRatioSync with conflict-modal component, performance hooks & cleanup (#1286)
WHAT’S NEW
• Extracted reusable ConflictConfirmModal for clearer JSX hierarchy
• Added detailed conflict detection & confirmation flow before syncing options
• Refactored state-heavy callbacks (`selectValue`, `performSync`) with `useCallback` to avoid unnecessary renders
• Introduced build-time constants (later removed unused export) and unified helper utilities
• Ensured final ratios are rebuilt accurately before API `PUT`, fixing “value not updated” bug
• Enhanced UI hints: warning icon on conflict, multiline billing info, mobile-friendly modal size
• General code cleanup: removed dead variables, adopted early returns, improved comments

WHY
Improves maintainability, user clarity when billing-type collisions occur, and guarantees data consistency after synchronisation.
2025-06-23 17:35:39 +08:00
t0ng7u
c28190316f 🐛 fix(ratio-sync): reset pagination when filter/search changes
Add a `useEffect` hook in `UpstreamRatioSync.js` to automatically set
`currentPage` to `1` whenever `ratioTypeFilter` or `searchKeyword`
updates.
This prevents the table from appearing empty when users switch to the
“model_price” (fixed price) filter or perform a new search while on a
later page.

Additional changes:
- Import `useEffect` from React.

This enhancement delivers a smoother UX by ensuring the first page of
results is always shown after any filtering action.
2025-06-23 16:34:00 +08:00
JoeyLearnsToCode
ffc22b8dac Merge branch 'main' into feat-copy-models 2025-06-23 16:12:18 +08:00
t0ng7u
5367015a31 🎛️ feat(web): add “Conflict Rates” filter & highlight in Model Settings Visual Editor (#1286)
Introduce the ability to quickly locate models with conflicting billing configurations.

Key points
• Added `hasConflict` flag to detect models that define both a fixed price (`ModelPrice`) and any ratio (`ModelRatio` or `CompletionRatio`).
• Added “Show Only Conflict Rates” `Checkbox` to toolbar; filtering logic now supports keyword + conflict filtering.
• Display a red `Tag` beside the model name when a conflict is detected for immediate visual feedback.
• Kept `hasConflict` state in sync during add, update and delete operations.
• Imported `Checkbox` and `Tag` from **@douyinfe/semi-ui**.
• Minor UI tweaks (circle tag style, margin) for consistency.

This enhancement helps administrators swiftly identify and resolve incompatible pricing rules, addressing the need discussed in issue #1286.
2025-06-23 15:55:10 +08:00
CaIon
75c71c397e 🔧 chore: update STREAMING_TIMEOUT default value to 120 seconds in configuration 2025-06-22 18:47:40 +08:00
t0ng7u
6192aebe66 🍭style: update home main title style font-semibold to font-bold 2025-06-22 18:28:32 +08:00
t0ng7u
a85a594597 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 18:10:44 +08:00
t0ng7u
014c9450ba feat(ui): Implement unified compact/adaptive table mode + icon refinement
Summary
• Added per-table “Compact / Adaptive” view toggle to all major table components (Tokens, Channels, Logs, MjLogs, TaskLogs, Redemptions, Users).
• Persist user preference in a single localStorage entry (`table_compact_modes`) instead of scattered keys.

Details
1. utils.js
   • Re-implemented `getTableCompactMode` / `setTableCompactMode` to read & write a shared JSON object.
   • Imported storage-key constant from `constants`.

2. hooks/useTableCompactMode.js
   • Hook now consumes the unified helpers and listens to `storage` events via the shared key constant.

3. constants
   • Added `TABLE_COMPACT_MODES_KEY` to `common.constant.js` and re-exported via `constants/index.js`.

4. Table components
   • Integrated `useTableCompactMode('<tableName>')`.
   • Dynamically remove `fixed: 'right'` column and horizontal `scroll` when in compact mode.
   • UI: toggle button placed at card title’s right; responsive layout on small screens.

5. UI polish
   • Replaced all lucide-react `List`/`ListIcon` usages with Semi UI `IconDescend` for consistency.
   • Restored correct icons where `Hash` was intended (TaskLogsTable).

Benefits
• Consistent UX for switching list density across the app.
• Cleaner localStorage footprint with easier future maintenance.
2025-06-22 18:10:00 +08:00
Calcium-Ion
63640f65e8 Merge pull request #1281 from QuantumNous/mj_usergroupratio
feat: support user-group-specific for mj and task
2025-06-22 18:08:11 +08:00
CaIon
fd040988a3 refactor: streamline price calculation in RelaySwapFace and RelayMidjourneySubmit functions 2025-06-22 17:52:48 +08:00
CaIon
f7c3b043b5 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 16:59:22 +08:00
CaIon
93e7675bc3 fix: reset channel key in UpdateChannel function 2025-06-22 16:59:06 +08:00
t0ng7u
d7c97d4d34 feat(tokens-table): add selectable copy modes for bulk token copy action
This commit enhances the “Copy Selected Tokens to Clipboard” feature in `TokensTable.js` by introducing a user-friendly modal that lets users choose how they want to copy tokens.

Changes made
• Replaced direct copy logic with a `Modal.info` dialog.
• Modal displays the prompt “Please choose your copy mode”.
• Added two buttons in a custom footer:
  – **Name + Secret**: copies `tokenName    sk-tokenKey`.
  – **Secret Only**: copies `sk-tokenKey`.
• Each button triggers the copy operation and closes the dialog.
• Maintains existing validations (e.g., selection check, clipboard feedback).

Benefits
• Gives users clear control over copy format, reducing manual editing.
• Aligns UI with Semi UI’s best practices via custom modal footer.

No backend/API changes are involved; all updates are limited to the front-end UI logic.
2025-06-22 16:49:44 +08:00
t0ng7u
dce794dbf7 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 16:39:52 +08:00
t0ng7u
093d86040f 🗑️ feat(token): implement batch token deletion API & front-end integration
• Back-end
  • model/token.go
    • Add `BatchDeleteTokens(ids []int, userId int)` – transactional DB removal + async Redis cache cleanup.
  • controller/token.go
    • Introduce `TokenBatch` DTO and `DeleteTokenBatch` handler calling the model layer; returns amount deleted.
  • router/api-router.go
    • Register `POST /api/token/batch` route (user-scoped).

• Front-end (TokensTable.js)
  • Replace per-token deletion loops with single request to `/api/token/batch`.
  • Display dynamic i18n message: “Deleted {{count}} tokens!”.
  • Add modal confirmation:
    • Title “Batch delete token”.
    • Content “Are you sure you want to delete the selected {{count}} tokens?”.
  • UI/UX tweaks
    • Responsive button group (flex-wrap, mobile line-break).
    • Clear `selectedKeys` after refresh / successful deletion to avoid ghost selections.

• i18n
  • Ensure placeholder style matches translation keys (`{{count}}`).

This commit delivers efficient, scalable token management and an improved user experience across devices.
2025-06-22 16:35:30 +08:00
creamlike1024
39617bc8c6 task userGroupRatio 2025-06-22 15:52:25 +08:00
creamlike1024
7da224ba92 fix: mj userGroupRatio 2025-06-22 15:47:30 +08:00
CaIon
df862732df fix: update JSON decoding and budget token handling in RequestOpenAI2ClaudeMessage 2025-06-22 01:15:01 +08:00
Calcium-Ion
fd4447f60a Merge pull request #1120 from neotf/feat-04
feat: enhance token usage details for upstream OpenRouter
2025-06-22 01:10:49 +08:00
Calcium-Ion
ea79d59aa0 Merge pull request #1235 from prnake/thinking-fix-0616
feat: openrouter format for claude request
2025-06-22 01:08:01 +08:00
CaIon
41b0cf406c Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 00:59:56 +08:00
CaIon
ef32cc8e0a fix(dto): change Created field type in OpenAITextResponse to any. (close #1131) 2025-06-22 00:59:39 +08:00
Calcium-Ion
ee8956b0e9 Merge pull request #1279 from feitianbubu/add-kling-key-placeholder
feat: add placeholder for kling AccessKey and SecretKey
2025-06-22 00:46:09 +08:00
t0ng7u
5ad9f8d931 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-21 22:31:54 +08:00
t0ng7u
ea379e1d0e 📝 feat(SettingsAnnouncements/SettingsFAQ): update placeholders & success texts, add Markdown/HTML hint
• SettingsAnnouncements.js
  – Placeholder now states “Supports Markdown/HTML” for both small & expanded editors
  – Success/alert messages unified to use Chinese quotation marks

• SettingsFAQ.js
  – Answer textarea placeholder updated with Markdown/HTML support note
  – Unified success/alert messages punctuation

These tweaks clarify rich-text support and keep UI copy consistent.
2025-06-21 22:31:19 +08:00
t0ng7u
b842baf21f feat(settings-faq): improve FAQ table readability with tooltip & text truncation
* Added `Tooltip` component and ellipsis style for “question” & “answer” columns
* Keeps table compact while showing full content on hover
* Updated success messages punctuation for consistency
2025-06-21 22:03:14 +08:00
t0ng7u
58c9c7d5dd feat(settings-announcements): improve editor UX with modal & tooltips
* Added “Expand Edit” button with `Maximize2` icon to open a large modal editor
* Introduced full-screen `TextArea` modal; content syncs back to main form via Form API
* Switched to correct `TextArea` import from Semi UI to fix invalid element error
* Implemented ellipsis & `Tooltip` for “content” and “extra” columns to keep table concise
* Added state management (`showContentModal`, `formApiRef`) and related handlers
* Updated success messages & punctuation for consistency
2025-06-21 21:59:38 +08:00
CaIon
384fadf227 feat(gemini): enhance ThinkingAdapter and model handling
- Introduced `isNoThinkingRequest` and `trimModelThinking` functions to manage model names and thinking configurations.
- Updated `GeminiHelper` to conditionally adjust the model name based on the thinking budget and request settings.
- Refactored `ThinkingAdaptor` to streamline the integration of thinking capabilities into Gemini requests.
- Cleaned up commented-out code in `FetchUpstreamModels` for clarity.

These changes improve the handling of model configurations and enhance the adaptability of the Gemini relay system.
2025-06-21 21:50:03 +08:00
skynono
e4def0625b feat: kling apiKey format to use | delimiter 2025-06-21 21:38:36 +08:00
skynono
44d20de251 feat: add placeholder for kling AccessKey and SecretKey 2025-06-21 20:38:22 +08:00
t0ng7u
7ea33c2ddf Merge remote-tracking branch 'origin/main' into alpha 2025-06-21 20:25:35 +08:00
t0ng7u
b43423bffc feat(ratio-sync): support /api/pricing parsing, confidence verification & UI enhancements
Backend
- controller/ratio_sync.go
  • Parse /api/pricing response and convert to ratio / price maps.
  • Introduce confidence heuristic (model_ratio = 37.5 && completion_ratio = 1) to flag unreliable data.
  • Include confidence map when building differences and filter “same”/empty entries.
- dto/ratio_sync.go
  • Add `ID` to UpstreamDTO, `upstreams` to UpstreamRequest, and `Confidence` to DifferenceItem.

Frontend
- ChannelSelectorModal.js
  • Re-implement with table layout, pagination, search, endpoint-type selector and mobile support.
- UpstreamRatioSync.js
  • Send full upstream objects, add ratio-type filter, confidence badges/tooltips, retain endpoints.
  • Leverage ChannelSelectorModal’s pagination reset.
- ChannelsTable.js – fix tag color for disabled status.
- en.json – add translations for new UI labels.

Motivation
These changes let users sync model ratios / prices from different upstream endpoints and visually identify potentially unreliable data, improving operational safety and flexibility.
2025-06-21 20:24:52 +08:00
Calcium-Ion
cf4700a35c Merge pull request #1278 from QuantumNous/alpha
feat: conditionally set Gemini ThinkingBudget based on MaxOutputTokens
2025-06-21 18:27:05 +08:00
CaIon
6bb552128c feat(relay-gemini): conditionally set ThinkingBudget based on MaxOutputTokens 2025-06-21 17:51:13 +08:00
CaIon
50b4fc06f8 Merge branch 'main' into alpha 2025-06-21 17:04:29 +08:00
t0ng7u
f7f1be9df2 🎨 refactor: Refactor RatioSetting: integrate Group Ratio Settings into tabs
* Moved `GroupRatioSettings` component inside the existing Tabs as a new **Group Ratios** tab.
* Removed the standalone `Card` that previously wrapped `GroupRatioSettings`.
* Re-formatted JSX props for `ModelRatioSettings` and `GroupRatioSettings` to improve readability.
* Consolidates all ratio-related settings into a single tabbed view for a cleaner and more consistent UI.
2025-06-21 15:09:48 +08:00
creamlike1024
59574dc80f Merge branch 'bddiudiu-feat_images' 2025-06-21 10:31:53 +08:00
creamlike1024
7577ec1ac4 Merge branch 'feat_images' of github.com:bddiudiu/new-api into bddiudiu-feat_images 2025-06-21 10:31:37 +08:00
t0ng7u
d487be0029 feat(settings/announcements): sort by publishDate desc
Add reverse-chronological sorting for the announcements list so that the newest
items appear first in the dashboard.

No API changes; this only affects front-end display and user notifications.
2025-06-21 06:15:26 +08:00
Apple\Apple
83a3872b97 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-21 06:06:35 +08:00
Apple\Apple
1ad2f63f85 feat: Enhance announcements UX with unread badge, tabbed NoticeModal, and shine animation
• HeaderBar
  - Added dynamic unread badge; click now opens NoticeModal on “System Announcements” tab
  - Passes `defaultTab` and `unreadKeys` props to NoticeModal for contextual behaviour

• NoticeModal
  - Introduced Tabs inside the modal title with Lucide icons (Bell, Megaphone)
  - Displays in-app notice (markdown) and system announcements separately
  - Highlights unread announcements with “shine” text animation
  - Accepts new props `defaultTab`, `unreadKeys` to control initial tab and highlight logic

• CSS (index.css)
  - Implemented `sweep-shine` keyframes and `.shine-text` utility for left-to-right glow
  - Added dark-mode variant for better contrast
  - Ensured cross-browser support with standard `background-clip`

Overall, users now see an unread counter, are directed to new announcements automatically, and benefit from an eye-catching glow effect that works in both light and dark themes.
2025-06-21 06:06:21 +08:00
Calcium-Ion
fcaa8317e4 Merge pull request #1160 from feitianbubu/add-moonshot-kimi-update-balance
feat: add moonshot(kimi) update balance
2025-06-21 05:03:45 +08:00
Calcium-Ion
ccda14255a Merge pull request #1135 from PeterDaveHelloKitchen/Dockerfile
refactor: optimize Dockerfile apk usage
2025-06-21 05:03:16 +08:00
Calcium-Ion
8d66828229 Merge pull request #1210 from RedwindA/feat/128-budget
feat: allow for a lower percentage of the thinkingBudget
2025-06-21 04:54:46 +08:00
Calcium-Ion
4ebf9e35e1 Merge pull request #1248 from RedwindA/update-gemini-ratio
feat(model-ratio): add default ratios for new Gemini models and refine flash model handling
2025-06-21 04:51:41 +08:00
t0ng7u
2902d6c7c2 🎨 style: add mt-2 style in ratio section 2025-06-21 04:25:56 +08:00
t0ng7u
01ef1fe4e4 📝 refactor: reorganize payment settings into dedicated tab
Restructure payment settings into a separate tab for better organization and user experience. The changes include:

1. Create dedicated Payment components in the Setting directory structure
2. Move payment-related settings from SystemSetting to PaymentSetting
3. Add proper i18n support with useTranslation hook
4. Split payment settings into GeneralPayment and PaymentGateway components
5. Fix internationalization issues in placeholder text
6. Update navigation with CreditCard icon for payment tab

This refactoring improves code maintainability by following the established project pattern of having specialized setting components in their own directories.
2025-06-21 04:16:01 +08:00
Apple\Apple
c3d2d07b68 🚚 refactor: Move DataDashboard settings from Operation to Dashboard section
This commit relocates the DataDashboard settings component from the Operation section to the Dashboard section for better logical organization. The changes include:

- Remove DataDashboard import and component from OperationSetting.js
- Add DataDashboard component to DashboardSetting.js
- Update import path from Operation to Dashboard directory
- Add DataExport related state management in DashboardSetting

This restructuring improves the application's information architecture by grouping related dashboard visualization settings together.
2025-06-21 02:56:38 +08:00
Apple\Apple
18417bacb3 🎨 refactor(UI): Move drawing settings to a separate tab
- Create a new DrawingSetting component for managing drawing-related configurations
- Add a dedicated "Drawing Settings" tab with Palette icon in the settings page
- Remove drawing settings section from the OperationSetting component
- Update import path to use Drawing directory instead of Operation directory
- Improve UI organization by separating drawing settings from general operations
2025-06-21 02:50:09 +08:00
Apple\Apple
8ec18dd21b 💬 refactor: separate chat settings into dedicated tab
- Create new ChatsSetting component for managing chat configurations
- Add "Chat Settings" tab with MessageSquare icon in settings page
- Remove chat settings section from OperationSetting component
- Update import path to use Chat directory structure
2025-06-21 02:36:09 +08:00
t0ng7u
edaff1c689 🎨 feat(UI): Add Lucide icons to settings tabs for improved navigation
- Add icons to each settings tab to enhance visual recognition
- Import necessary Lucide React icons (Settings, Calculator, Gauge, Shapes, etc.)
- Create consistent tab styling with icons aligned next to text
- Reorder tabs to place "Other Settings" as the last option
- Improve overall settings page UI with better visual hierarchy
2025-06-21 02:21:27 +08:00
Apple\Apple
9c3a13cb23 📝 i18n: Update ratio sync message text
- Change message from "已与上游倍率完全一致,无需同步" to "未找到差异化倍率,无需同步"
- Update English translation to "No differential ratio found, no synchronization is required"
- Improve user experience clarity for upstream ratio synchronization status
2025-06-21 02:09:08 +08:00
Apple\Apple
0b326e7af4 🌐 feat(i18n): add English translation for "暴露倍率接口"
- Add translation "Expose ratio API" for Chinese text "暴露倍率接口"
- Update English locale file (en.json) with new translation entry
2025-06-21 02:00:58 +08:00
CaIon
1a1ff836b5 Merge branch 'alpha' 2025-06-21 01:26:57 +08:00
Calcium-Ion
34fed74f64 Merge pull request #1252 from feitianbubu/pr/fix-playgroud-user-setting
fix: playground write user context to check acceptUnsetRatio
2025-06-21 01:25:22 +08:00
Calcium-Ion
f89b29928c Merge pull request #1264 from feitianbubu/pr/uniq-channel-models
fix: unique channel models
2025-06-21 01:21:28 +08:00
Calcium-Ion
2c6d4460c3 Merge pull request #1272 from QuantumNous/gemini-stream-completion-count-fix
fix: gemini 原生格式流模式中断请求未计费
2025-06-21 01:21:01 +08:00
CaIon
7afd3f97ee fix: remove unnecessary error handling in token counting functions 2025-06-21 01:16:54 +08:00
CaIon
0708452939 fix: improve usage calculation in GeminiTextGenerationStreamHandler 2025-06-21 01:08:15 +08:00
CaIon
a9e5d99ea3 refactor: token counter logic 2025-06-21 00:54:40 +08:00
creamlike1024
a56d9ea98b fix: gemini 原生格式流模式中断请求未计费 2025-06-20 23:01:10 +08:00
CaIon
f5e80af0b3 fix: update response handling in GeminiTextGenerationStreamHandler
- Changed response handling from ObjectData to StringData for improved data processing.
- Ensured proper error logging in case of response handling failure.
2025-06-20 21:55:28 +08:00
CaIon
a1a7ddbc83 fix: update payment method handling in topup controller
- Refactored payment method validation to check against available methods.
- Changed payment method types from "zfb" to "alipay" and "wx" to "wxpay" for consistency.
- Updated the purchase request to use the validated payment method directly.
2025-06-20 17:48:55 +08:00
Calcium-Ion
8b209d8926 Merge pull request #1271 from RedwindA/feat/vertex-budgetControl
feat: vertex budget control in model name
2025-06-20 17:24:42 +08:00
RedwindA
9344cab59a fix: update model name logic for vertex 2025-06-20 16:40:51 +08:00
Calcium-Ion
03468e05e4 Merge pull request #1267 from t0ng7u/feature/upstream-ratio-sync
🔄 feat(ratio-sync): introduce upstream ratio synchronisation feature #1220
2025-06-20 16:22:00 +08:00
Calcium-Ion
11792ba1a4 Merge pull request #1270 from QuantumNous/refactor_model_mapping
feat: implement new handlers for relay processing
2025-06-20 16:12:41 +08:00
Calcium-Ion
5baaa06896 Merge pull request #1244 from feitianbubu/feat/video
feat: 支持可灵视频渠道(异步任务)
2025-06-20 16:11:59 +08:00
CaIon
d3286893c4 feat: implement new handlers for audio, image, embedding, and responses processing
- Added new handlers: AudioHelper, ImageHelper, EmbeddingHelper, and ResponsesHelper to manage respective requests.
- Updated ModelMappedHelper to accept request parameters for better model mapping.
- Enhanced error handling and validation across new handlers to ensure robust request processing.
- Introduced support for new relay formats in relay_info and updated relevant functions accordingly.
2025-06-20 16:02:23 +08:00
CaIon
b087b20bac refactor: update error handling in ClaudeHelper and GeminiHelper 2025-06-20 14:53:27 +08:00
RedwindA
c192d07a04 fix gizmo completion ratio 2025-06-19 20:16:04 +08:00
RedwindA
098880b796 Merge remote-tracking branch 'upstream/alpha' into update-gemini-ratio 2025-06-19 20:02:27 +08:00
Apple\Apple
150c506ece 🚀 chore(controller, dto): elevate ratio-sync feature to production readiness
WHAT’S NEW
• controller/ratio_sync.go
  – Deleted unused local structs (TestResult, DifferenceItem, SyncableChannel).
  – Centralised config with constants: defaultTimeoutSeconds, defaultEndpoint, maxConcurrentFetches, ratioTypes.
  – Replaced magic numbers; added semaphore-based concurrency limit and shared http.Client (with TLS & Expect-Continue timeouts).
  – Added comprehensive error handling and context-aware logging via common.Log* helpers.
  – Checked DB errors from GetChannelsByIds; early-return on failures or empty upstream list.
  – Removed custom-channel support; logic now relies solely on ChannelIDs.
  – Minor clean-ups: import grouping, string trimming, endpoint normalisation.

• dto/ratio_sync.go
  – Simplified UpstreamRequest: dropped unused CustomChannels field.

WHY
These improvements harden the ratio-sync endpoint for production use by preventing silent failures, controlling resource usage, and making behaviour configurable and observable.

HOW
No business logic change—only structural refactor, logging, and safeguards—so existing API contracts (aside from removed custom_channels) remain intact.
2025-06-19 19:55:51 +08:00
Apple\Apple
458472f3e2 🔍 feat(ratio-sync): add fuzzy model search & enhance empty-state UX
Summary
1. Add model name search box
   • Introduce Semi UI `Input` with `IconSearch` prefix next to the “Apply Sync” button.
   • Support case-insensitive fuzzy matching of model names.
   • Real-time filtering, pagination and bulk-select logic now work on filtered data.

2. Improve empty state handling
   • Add `hasSynced` flag to distinguish “not synced yet” from “synced with no differences”.
   • Display messages:
     – “Please select sync channels” when no sync has been performed.
     – “No differences found” when a sync completed with zero discrepancies.
     – “No matching model found” when search yields no results.

3. UI tweaks
   • Replace lucide-react `Search` icon with Semi UI `IconSearch` for visual consistency.
   • Keep responsive width and clearable input for better usability.

Why
These changes allow admins to quickly locate specific models and provide accurate feedback on the sync status, greatly improving the usability of the Upstream Ratio Sync page.
2025-06-19 18:54:46 +08:00
Apple\Apple
a9f98c5d39 🛠️ chore(ratio-sync): improve upstream ratio comparison & output cleanliness
Summary
1. Consider “both unset” as identical
   • When both localValue and upstreamValue are nil, mark upstreamValue as "same" to avoid showing “Not set”.

2. Exclude fully-synced upstream channels from result
   • Scan `differences` to detect channels that contain at least one divergent value.
   • Remove channels whose every ratio is either `"same"` or `nil`, so the frontend only receives actionable discrepancies.

Why
These changes reduce visual noise in the Upstream Ratio Sync table, making it easier for admins to focus on models requiring attention. No functional regressions or breaking API changes are introduced.
2025-06-19 18:38:43 +08:00
Apple\Apple
67546f4b2a chore(ui): enhance channel selector with status avatars and UI improvements
Add visual status indicators and improve user experience for the upstream ratio sync channel selector modal.

Features:
- Add status-based avatar indicators for channels (enabled/disabled/auto-disabled)
- Implement search functionality with text highlighting
- Add endpoint configuration input for each channel
- Optimize component structure with reusable ChannelInfo component

UI Improvements:
- Custom styling for transfer component items
- Hide scrollbars for cleaner appearance in transfer lists
- Responsive layout adjustments for channel information display
- Color-coded avatars: green (enabled), red (disabled), amber (auto-disabled), grey (unknown)

Code Quality:
- Extract channel status configuration to constants
- Create reusable ChannelInfo component to reduce code duplication
- Implement proper search filtering for both channel names and URLs
- Add consistent styling classes for transfer demo components

Files modified:
- web/src/components/settings/ChannelSelectorModal.js
- web/src/pages/Setting/Ratio/UpstreamRatioSync.js
- web/src/index.css

This enhancement provides better visual feedback for channel status and improves the overall user experience when selecting channels for ratio synchronization.
2025-06-19 16:05:50 +08:00
Apple\Apple
fb4ff63bad 🗑️ chore(custom channel): Remove custom channel support from upstream ratio sync
Remove all custom channel functionality from the upstream ratio sync feature to simplify the codebase and focus on database-stored channels only.

Changes:
- Remove custom channel UI components and related state management
- Remove custom channel testing and validation logic
- Simplify ChannelSelectorModal by removing custom channel input fields
- Update API payload to only include channel_ids, removing custom_channels
- Remove custom channel processing logic from backend controller
- Update import path for DEFAULT_ENDPOINT constant

Files modified:
- web/src/pages/Setting/Ratio/UpstreamRatioSync.js
- web/src/components/settings/ChannelSelectorModal.js
- controller/ratio_sync.go

This change streamlines the ratio synchronization workflow by focusing solely on pre-configured database channels, reducing complexity and potential maintenance overhead.
2025-06-19 15:17:05 +08:00
skynono
1fed1ee567 fix: unique channel models 2025-06-19 15:16:26 +08:00
skynono
616e6953cc feat: add unsupported test case for kling channel 2025-06-19 11:53:47 +08:00
skynono
b7c77777a5 feat: add video channel kling fix 2025-06-19 11:53:47 +08:00
skynono
8a79de333a feat: add video channel kling 2025-06-19 11:53:42 +08:00
Apple\Apple
7975cdf3bf 🚀 feat(ratio-sync): major refactor & UX overhaul for Upstream Ratio Sync 2025-06-19 08:57:34 +08:00
neotf
16c63b3be9 fix(quota): refine cache token calculation for OpenRouter channel type 2025-06-18 20:11:48 +08:00
neotf
37fbcb7950 format 2025-06-18 19:54:20 +08:00
neotf
a6363a502a Update relay/channel/openai/adaptor.go
use review's suggestion

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-18 15:29:19 +08:00
neotf
81bc096872 Merge branch 'main' into feat-04 2025-06-18 15:20:24 +08:00
skynono
fb8aac650f fix: playground write user context to check acceptUnsetRatio 2025-06-18 11:06:15 +08:00
RedwindA
b95c5bb8f4 feat(gemini): add pricing for Gemini 2.5 Flash Lite preview audio input 2025-06-18 03:38:58 +08:00
RedwindA
f35784aa97 feat(gemini): update audio input pricing and adjust model handling logic 2025-06-18 03:25:59 +08:00
RedwindA
a39b2f5aa7 feat(ratio): add new Gemini model ratios and enhance flash model handling 2025-06-18 01:09:09 +08:00
Papersnake
296da5dbcc feat: openrouter format for claude request 2025-06-16 17:43:39 +08:00
RedwindA
1ec2bbd533 update input range and description for thinking adapter budget tokens 2025-06-12 18:20:58 +08:00
neotf
d67d5d8006 format 2025-06-11 14:00:32 +08:00
neotf
c4f25a77d1 format 2025-06-11 13:56:44 +08:00
neotf
52763c09f2 Merge branch 'main' into feat-04 2025-06-11 13:55:47 +08:00
neotf
e77555a04f Merge branch 'main' into feat-04 2025-06-05 20:35:47 +08:00
skynono
4a313a5f93 feat: add moonshot(kimi) update balance 2025-06-05 18:16:37 +08:00
neotf
3d9587f128 feat: enhance cache_create_tokens calculation for OpenRouter 2025-05-29 22:47:02 +08:00
neotf
66778efcc5 feat: enhance token usage details for upstream OpenRouter 2025-05-29 00:55:57 +08:00
Adam.Wang
6be78ff283 feat: 火山引擎增加文生图 2025-05-23 16:42:53 +08:00
Adam.Wang
5281f2ba64 feat: 火山引擎增加文生图 2025-05-22 13:58:05 +08:00
JoeyLearnsToCode
69420f713f feat: 渠道编辑页增加复制所有模型功能 2025-05-19 19:33:29 +08:00
Peter Dave Hello
bc322ddac4 refactor: optimize Dockerfile apk usage 2025-04-29 22:54:43 +08:00
122 changed files with 5310 additions and 1118 deletions

View File

@@ -59,7 +59,7 @@
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# 设置流式一次回复的超时时间
# STREAMING_TIMEOUT=90
# STREAMING_TIMEOUT=120
# 节点类型

View File

@@ -24,8 +24,7 @@ RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-
FROM alpine
RUN apk update \
&& apk upgrade \
RUN apk upgrade --no-cache \
&& apk add --no-cache ca-certificates tzdata ffmpeg \
&& update-ca-certificates

View File

@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 60 seconds
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`

View File

@@ -103,7 +103,7 @@ New API提供了丰富的功能详细特性请参考[特性说明](https://do
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`:流式回复超时时间,默认60秒
- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
- `DIFY_DEBUG`Dify渠道是否输出工作流和节点信息默认 `true`
- `FORCE_STREAM_OPTION`是否覆盖客户端stream_options参数默认 `true`
- `GET_MEDIA_TOKEN`是否统计图片token默认 `true`

View File

@@ -241,6 +241,7 @@ const (
ChannelTypeXinference = 47
ChannelTypeXai = 48
ChannelTypeCoze = 49
ChannelTypeKling = 50
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -296,4 +297,5 @@ var ChannelBaseURLs = []string{
"", //47
"https://api.x.ai", //48
"https://api.coze.cn", //49
"https://api.klingai.com", //50
}

View File

@@ -13,6 +13,7 @@ import (
"math/big"
"math/rand"
"net"
"net/url"
"os"
"os/exec"
"runtime"
@@ -284,3 +285,20 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
}
return strconv.ParseFloat(durationStr, 64)
}
// BuildURL concatenates base and endpoint, returns the complete url string
func BuildURL(base string, endpoint string) string {
u, err := url.Parse(base)
if err != nil {
return base + endpoint
}
end := endpoint
if end == "" {
end = "/"
}
ref, err := url.Parse(end)
if err != nil {
return base + endpoint
}
return u.ResolveReference(ref).String()
}

View File

@@ -7,4 +7,5 @@ const (
ContextKeyUserStatus = "user_status"
ContextKeyUserEmail = "user_email"
ContextKeyUserGroup = "user_group"
ContextKeyUsingGroup = "group"
)

View File

@@ -23,7 +23,7 @@ var ErrorLogEnabled bool
//}
func InitEnv() {
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 60)
StreamingTimeout = common.GetEnvOrDefault("STREAMING_TIMEOUT", 120)
DifyDebug = common.GetEnvOrDefaultBool("DIFY_DEBUG", true)
MaxFileDownloadMB = common.GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
// ForceStreamOption 覆盖请求参数强制返回usage信息

View File

@@ -5,6 +5,7 @@ type TaskPlatform string
const (
TaskPlatformSuno TaskPlatform = "suno"
TaskPlatformMidjourney = "mj"
TaskPlatformKling TaskPlatform = "kling"
)
const (

View File

@@ -4,11 +4,13 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/shopspring/decimal"
"io"
"net/http"
"one-api/common"
"one-api/model"
"one-api/service"
"one-api/setting"
"strconv"
"time"
@@ -304,6 +306,40 @@ func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) {
return balance, nil
}
func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
url := "https://api.moonshot.cn/v1/users/me/balance"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
type MoonshotBalanceData struct {
AvailableBalance float64 `json:"available_balance"`
VoucherBalance float64 `json:"voucher_balance"`
CashBalance float64 `json:"cash_balance"`
}
type MoonshotBalanceResponse struct {
Code int `json:"code"`
Data MoonshotBalanceData `json:"data"`
Scode string `json:"scode"`
Status bool `json:"status"`
}
response := MoonshotBalanceResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
if !response.Status || response.Code != 0 {
return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
}
availableBalanceCny := response.Data.AvailableBalance
availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
channel.UpdateBalance(availableBalanceUsd)
return availableBalanceUsd, nil
}
func updateChannelBalance(channel *model.Channel) (float64, error) {
baseURL := common.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() == "" {
@@ -332,6 +368,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return updateChannelDeepSeekBalance(channel)
case common.ChannelTypeOpenRouter:
return updateChannelOpenRouterBalance(channel)
case common.ChannelTypeMoonshot:
return updateChannelMoonshotBalance(channel)
default:
return 0, errors.New("尚未实现")
}

View File

@@ -40,6 +40,9 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
if channel.Type == common.ChannelTypeSunoAPI {
return errors.New("suno channel test is not supported"), nil
}
if channel.Type == common.ChannelTypeKling {
return errors.New("kling channel test is not supported"), nil
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -90,7 +93,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
info := relaycommon.GenRelayInfo(c)
err = helper.ModelMappedHelper(c, info)
err = helper.ModelMappedHelper(c, info, nil)
if err != nil {
return err, nil
}
@@ -168,7 +171,7 @@ func testChannel(channel *model.Channel, testModel string) (err error, openAIErr
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(c, 1, channel.Id, usage.PromptTokens, usage.CompletionTokens, info.OriginModelName, "模型测试",
quota, "模型测试", 0, quota, int(consumedTime), false, info.Group, other)
quota, "模型测试", 0, quota, int(consumedTime), false, info.UsingGroup, other)
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return nil, nil
}

View File

@@ -40,6 +40,17 @@ type OpenAIModelsResponse struct {
Success bool `json:"success"`
}
func parseStatusFilter(statusParam string) int {
switch strings.ToLower(statusParam) {
case "enabled", "1":
return common.ChannelStatusEnabled
case "disabled", "0":
return 0
default:
return -1
}
}
func GetAllChannels(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
@@ -52,6 +63,9 @@ func GetAllChannels(c *gin.Context) {
channelData := make([]*model.Channel, 0)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
statusParam := c.Query("status")
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
statusFilter := parseStatusFilter(statusParam)
// type filter
typeStr := c.Query("type")
typeFilter := -1
@@ -64,42 +78,75 @@ func GetAllChannels(c *gin.Context) {
var total int64
if enableTagMode {
// tag 分页:先分页 tag再取各 tag 下 channels
tags, err := model.GetPaginatedTags((p-1)*pageSize, pageSize)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
if err == nil {
channelData = append(channelData, tagChannel...)
}
if tag == nil || *tag == "" {
continue
}
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
if err != nil {
continue
}
filtered := make([]*model.Channel, 0)
for _, ch := range tagChannels {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
if typeFilter >= 0 && ch.Type != typeFilter {
continue
}
filtered = append(filtered, ch)
}
channelData = append(channelData, filtered...)
}
// 计算 tag 总数用于分页
total, _ = model.CountAllTags()
} else if typeFilter >= 0 {
channels, err := model.GetChannelsByType((p-1)*pageSize, pageSize, idSort, typeFilter)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountChannelsByType(typeFilter)
} else {
channels, err := model.GetAllChannels((p-1)*pageSize, pageSize, false, idSort)
baseQuery := model.DB.Model(&model.Channel{})
if typeFilter >= 0 {
baseQuery = baseQuery.Where("type = ?", typeFilter)
}
if statusFilter == common.ChannelStatusEnabled {
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
}
baseQuery.Count(&total)
order := "priority desc"
if idSort {
order = "id desc"
}
err := baseQuery.Order(order).Limit(pageSize).Offset((p-1)*pageSize).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
channelData = channels
total, _ = model.CountAllChannels()
}
// calculate type counts
typeCounts, _ := model.CountChannelsGroupByType()
countQuery := model.DB.Model(&model.Channel{})
if statusFilter == common.ChannelStatusEnabled {
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
} else if statusFilter == 0 {
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
}
var results []struct {
Type int64
Count int64
}
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
typeCounts := make(map[int64]int64)
for _, r := range results {
typeCounts[r.Type] = r.Count
}
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -134,13 +181,6 @@ func FetchUpstreamModels(c *gin.Context) {
return
}
//if channel.Type != common.ChannelTypeOpenAI {
// c.JSON(http.StatusOK, gin.H{
// "success": false,
// "message": "仅支持 OpenAI 类型渠道",
// })
// return
//}
baseURL := common.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
@@ -206,6 +246,8 @@ func SearchChannels(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
modelKeyword := c.Query("model")
statusParam := c.Query("status")
statusFilter := parseStatusFilter(statusParam)
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
channelData := make([]*model.Channel, 0)
@@ -238,17 +280,71 @@ func SearchChannels(c *gin.Context) {
channelData = channels
}
if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
continue
}
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
continue
}
filtered = append(filtered, ch)
}
channelData = filtered
}
// calculate type counts for search results
typeCounts := make(map[int64]int64)
for _, channel := range channelData {
typeCounts[int64(channel.Type)]++
}
typeParam := c.Query("type")
typeFilter := -1
if typeParam != "" {
if tp, err := strconv.Atoi(typeParam); err == nil {
typeFilter = tp
}
}
if typeFilter >= 0 {
filtered := make([]*model.Channel, 0, len(channelData))
for _, ch := range channelData {
if ch.Type == typeFilter {
filtered = append(filtered, ch)
}
}
channelData = filtered
}
page, _ := strconv.Atoi(c.DefaultQuery("p", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
total := len(channelData)
startIdx := (page - 1) * pageSize
if startIdx > total {
startIdx = total
}
endIdx := startIdx + pageSize
if endIdx > total {
endIdx = total
}
pagedData := channelData[startIdx:endIdx]
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"items": channelData,
"items": pagedData,
"total": total,
"type_counts": typeCounts,
},
})
@@ -546,6 +642,7 @@ func UpdateChannel(c *gin.Context) {
})
return
}
channel.Key = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@@ -2,6 +2,7 @@ package controller
import (
"fmt"
"github.com/samber/lo"
"net/http"
"one-api/common"
"one-api/constant"
@@ -136,6 +137,9 @@ func init() {
adaptor.Init(meta)
channelId2Models[i] = adaptor.GetModelList()
}
openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string {
return m.Id
})
}
func ListModels(c *gin.Context) {

View File

@@ -66,5 +66,14 @@ func Playground(c *gin.Context) {
}
middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
c.Set(constant.ContextKeyRequestStartTime, time.Now())
// Write user context to ensure acceptUnsetRatio is available
userId := c.GetInt("id")
userCache, err := model.GetUserCache(userId)
if err != nil {
openaiErr = service.OpenAIErrorWrapperLocal(err, "get_user_cache_failed", http.StatusInternalServerError)
return
}
userCache.WriteContext(c)
Relay(c)
}

View File

@@ -0,0 +1,24 @@
package controller
import (
"net/http"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
func GetRatioConfig(c *gin.Context) {
if !ratio_setting.IsExposeRatioEnabled() {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "倍率配置接口未启用",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": ratio_setting.GetExposedData(),
})
}

474
controller/ratio_sync.go Normal file
View File

@@ -0,0 +1,474 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"one-api/common"
"one-api/dto"
"one-api/model"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
const (
defaultTimeoutSeconds = 10
defaultEndpoint = "/api/ratio_config"
maxConcurrentFetches = 8
)
var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
type upstreamResult struct {
Name string `json:"name"`
Data map[string]any `json:"data,omitempty"`
Err string `json:"err,omitempty"`
}
func FetchUpstreamRatios(c *gin.Context) {
var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
if req.Timeout <= 0 {
req.Timeout = defaultTimeoutSeconds
}
var upstreams []dto.UpstreamDTO
if len(req.Upstreams) > 0 {
for _, u := range req.Upstreams {
if strings.HasPrefix(u.BaseURL, "http") {
if u.Endpoint == "" {
u.Endpoint = defaultEndpoint
}
u.BaseURL = strings.TrimRight(u.BaseURL, "/")
upstreams = append(upstreams, u)
}
}
} else if len(req.ChannelIDs) > 0 {
intIds := make([]int, 0, len(req.ChannelIDs))
for _, id64 := range req.ChannelIDs {
intIds = append(intIds, int(id64))
}
dbChannels, err := model.GetChannelsByIds(intIds)
if err != nil {
common.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
return
}
for _, ch := range dbChannels {
if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
upstreams = append(upstreams, dto.UpstreamDTO{
ID: ch.Id,
Name: ch.Name,
BaseURL: strings.TrimRight(base, "/"),
Endpoint: "",
})
}
}
}
if len(upstreams) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
return
}
var wg sync.WaitGroup
ch := make(chan upstreamResult, len(upstreams))
sem := make(chan struct{}, maxConcurrentFetches)
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
for _, chn := range upstreams {
wg.Add(1)
go func(chItem dto.UpstreamDTO) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
endpoint := chItem.Endpoint
if endpoint == "" {
endpoint = defaultEndpoint
} else if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
}
fullURL := chItem.BaseURL + endpoint
uniqueName := chItem.Name
if chItem.ID != 0 {
uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
}
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
resp, err := client.Do(httpReq)
if err != nil {
common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
return
}
// 兼容两种上游接口格式:
// type1: /api/ratio_config -> data 为 map[string]any包含 model_ratio/completion_ratio/cache_ratio/model_price
// type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
var body struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
if !body.Success {
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
return
}
// 尝试按 type1 解析
var type1Data map[string]any
if err := json.Unmarshal(body.Data, &type1Data); err == nil {
// 如果包含至少一个 ratioTypes 字段,则认为是 type1
isType1 := false
for _, rt := range ratioTypes {
if _, ok := type1Data[rt]; ok {
isType1 = true
break
}
}
if isType1 {
ch <- upstreamResult{Name: uniqueName, Data: type1Data}
return
}
}
// 如果不是 type1则尝试按 type2 (/api/pricing) 解析
var pricingItems []struct {
ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
CompletionRatio float64 `json:"completion_ratio"`
}
if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
return
}
modelRatioMap := make(map[string]float64)
completionRatioMap := make(map[string]float64)
modelPriceMap := make(map[string]float64)
for _, item := range pricingItems {
if item.QuotaType == 1 {
modelPriceMap[item.ModelName] = item.ModelPrice
} else {
modelRatioMap[item.ModelName] = item.ModelRatio
// completionRatio 可能为 0此时也直接赋值保持与上游一致
completionRatioMap[item.ModelName] = item.CompletionRatio
}
}
converted := make(map[string]any)
if len(modelRatioMap) > 0 {
ratioAny := make(map[string]any, len(modelRatioMap))
for k, v := range modelRatioMap {
ratioAny[k] = v
}
converted["model_ratio"] = ratioAny
}
if len(completionRatioMap) > 0 {
compAny := make(map[string]any, len(completionRatioMap))
for k, v := range completionRatioMap {
compAny[k] = v
}
converted["completion_ratio"] = compAny
}
if len(modelPriceMap) > 0 {
priceAny := make(map[string]any, len(modelPriceMap))
for k, v := range modelPriceMap {
priceAny[k] = v
}
converted["model_price"] = priceAny
}
ch <- upstreamResult{Name: uniqueName, Data: converted}
}(chn)
}
wg.Wait()
close(ch)
localData := ratio_setting.GetExposedData()
var testResults []dto.TestResult
var successfulChannels []struct {
name string
data map[string]any
}
for r := range ch {
if r.Err != "" {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "error",
Error: r.Err,
})
} else {
testResults = append(testResults, dto.TestResult{
Name: r.Name,
Status: "success",
})
successfulChannels = append(successfulChannels, struct {
name string
data map[string]any
}{name: r.Name, data: r.Data})
}
}
differences := buildDifferences(localData, successfulChannels)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"differences": differences,
"test_results": testResults,
},
})
}
func buildDifferences(localData map[string]any, successfulChannels []struct {
name string
data map[string]any
}) map[string]map[string]dto.DifferenceItem {
differences := make(map[string]map[string]dto.DifferenceItem)
allModels := make(map[string]struct{})
for _, ratioType := range ratioTypes {
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
for modelName := range localRatio {
allModels[modelName] = struct{}{}
}
}
}
}
for _, channel := range successfulChannels {
for _, ratioType := range ratioTypes {
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
for modelName := range upstreamRatio {
allModels[modelName] = struct{}{}
}
}
}
}
confidenceMap := make(map[string]map[string]bool)
// 预处理阶段检查pricing接口的可信度
for _, channel := range successfulChannels {
confidenceMap[channel.name] = make(map[string]bool)
modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
if hasModelRatio && hasCompletionRatio {
// 遍历所有模型,检查是否满足不可信条件
for modelName := range allModels {
// 默认为可信
confidenceMap[channel.name][modelName] = true
// 检查是否满足不可信条件model_ratio为37.5且completion_ratio为1
if modelRatioVal, ok := modelRatios[modelName]; ok {
if completionRatioVal, ok := completionRatios[modelName]; ok {
// 转换为float64进行比较
if modelRatioFloat, ok := modelRatioVal.(float64); ok {
if completionRatioFloat, ok := completionRatioVal.(float64); ok {
if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
confidenceMap[channel.name][modelName] = false
}
}
}
}
}
}
} else {
// 如果不是从pricing接口获取的数据则全部标记为可信
for modelName := range allModels {
confidenceMap[channel.name][modelName] = true
}
}
}
for modelName := range allModels {
for _, ratioType := range ratioTypes {
var localValue interface{} = nil
if localRatioAny, ok := localData[ratioType]; ok {
if localRatio, ok := localRatioAny.(map[string]float64); ok {
if val, exists := localRatio[modelName]; exists {
localValue = val
}
}
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
for _, channel := range successfulChannels {
var upstreamValue interface{} = nil
if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
if val, exists := upstreamRatio[modelName]; exists {
upstreamValue = val
hasUpstreamValue = true
if localValue != nil && localValue != val {
hasDifference = true
} else if localValue == val {
upstreamValue = "same"
}
}
}
if upstreamValue == nil && localValue == nil {
upstreamValue = "same"
}
if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
hasDifference = true
}
upstreamValues[channel.name] = upstreamValue
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
}
shouldInclude := false
if localValue != nil {
if hasDifference {
shouldInclude = true
}
} else {
if hasUpstreamValue {
shouldInclude = true
}
}
if shouldInclude {
if differences[modelName] == nil {
differences[modelName] = make(map[string]dto.DifferenceItem)
}
differences[modelName][ratioType] = dto.DifferenceItem{
Current: localValue,
Upstreams: upstreamValues,
Confidence: confidenceValues,
}
}
}
}
channelHasDiff := make(map[string]bool)
for _, ratioMap := range differences {
for _, item := range ratioMap {
for chName, val := range item.Upstreams {
if val != nil && val != "same" {
channelHasDiff[chName] = true
}
}
}
}
for modelName, ratioMap := range differences {
for ratioType, item := range ratioMap {
for chName := range item.Upstreams {
if !channelHasDiff[chName] {
delete(item.Upstreams, chName)
delete(item.Confidence, chName)
}
}
allSame := true
for _, v := range item.Upstreams {
if v != "same" {
allSame = false
break
}
}
if len(item.Upstreams) == 0 || allSame {
delete(ratioMap, ratioType)
} else {
differences[modelName][ratioType] = item
}
}
if len(ratioMap) == 0 {
delete(differences, modelName)
}
}
return differences
}
func GetSyncableChannels(c *gin.Context) {
channels, err := model.GetAllChannels(0, 0, true, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
var syncableChannels []dto.SyncableChannel
for _, channel := range channels {
if channel.GetBaseURL() != "" {
syncableChannels = append(syncableChannels, dto.SyncableChannel{
ID: channel.Id,
Name: channel.Name,
BaseURL: channel.GetBaseURL(),
Status: channel.Status,
})
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": syncableChannels,
})
}

View File

@@ -420,7 +420,7 @@ func RelayTask(c *gin.Context) {
func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
var err *dto.TaskError
switch relayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID:
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID:
err = relay.RelayTaskFetch(c, relayMode)
default:
err = relay.RelayTaskSubmit(c, relayMode)

View File

@@ -74,6 +74,8 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
case constant.TaskPlatformSuno:
_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
case constant.TaskPlatformKling:
_ = UpdateVideoTaskAll(context.Background(), taskChannelM, taskM)
default:
common.SysLog("未知平台")
}

140
controller/task_video.go Normal file
View File

@@ -0,0 +1,140 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/model"
"one-api/relay"
"one-api/relay/channel"
)
func UpdateVideoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
for channelId, taskIds := range taskChannelM {
if err := updateVideoTaskAll(ctx, channelId, taskIds, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
}
}
return nil
}
func updateVideoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
if len(taskIds) == 0 {
return nil
}
cacheGetChannel, err := model.CacheGetChannel(channelId)
if err != nil {
errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{
"fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId),
"status": "FAILURE",
"progress": "100%",
})
if errUpdate != nil {
common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
}
return fmt.Errorf("CacheGetChannel failed: %w", err)
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatformKling)
if adaptor == nil {
return fmt.Errorf("video adaptor not found")
}
for _, taskId := range taskIds {
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
}
}
return nil
}
func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error {
baseURL := common.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
"task_id": taskId,
})
if err != nil {
return fmt.Errorf("FetchTask failed for task %s: %w", taskId, err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Get Video Task status code: %d", resp.StatusCode)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("ReadAll failed for task %s: %w", taskId, err)
}
var responseItem map[string]interface{}
err = json.Unmarshal(responseBody, &responseItem)
if err != nil {
common.LogError(ctx, fmt.Sprintf("Failed to parse video task response body: %v, body: %s", err, string(responseBody)))
return fmt.Errorf("Unmarshal failed for task %s: %w", taskId, err)
}
code, _ := responseItem["code"].(float64)
if code != 0 {
return fmt.Errorf("video task fetch failed for task %s", taskId)
}
data, ok := responseItem["data"].(map[string]interface{})
if !ok {
common.LogError(ctx, fmt.Sprintf("Video task data format error: %s", string(responseBody)))
return fmt.Errorf("video task data format error for task %s", taskId)
}
task := taskM[taskId]
if task == nil {
common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
return fmt.Errorf("task %s not found", taskId)
}
if status, ok := data["task_status"].(string); ok {
switch status {
case "submitted", "queued":
task.Status = model.TaskStatusSubmitted
case "processing":
task.Status = model.TaskStatusInProgress
case "succeed":
task.Status = model.TaskStatusSuccess
task.Progress = "100%"
if url, err := adaptor.ParseResultUrl(responseItem); err == nil {
task.FailReason = url
} else {
common.LogWarn(ctx, fmt.Sprintf("Failed to get url from body for task %s: %s", task.TaskID, err.Error()))
}
case "failed":
task.Status = model.TaskStatusFailure
task.Progress = "100%"
if reason, ok := data["fail_reason"].(string); ok {
task.FailReason = reason
}
}
}
// If task failed, refund quota
if task.Status == model.TaskStatusFailure {
common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
quota := task.Quota
if quota != 0 {
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
common.LogError(ctx, "Failed to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
}
task.Data = responseBody
if err := task.Update(); err != nil {
common.SysError("UpdateVideoTask task error: " + err.Error())
}
return nil
}

View File

@@ -258,3 +258,32 @@ func UpdateToken(c *gin.Context) {
})
return
}
type TokenBatch struct {
Ids []int `json:"ids"`
}
func DeleteTokenBatch(c *gin.Context) {
tokenBatch := TokenBatch{}
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
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(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
})
}

View File

@@ -97,14 +97,12 @@ func RequestEpay(c *gin.Context) {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
}
payType := "wxpay"
if req.PaymentMethod == "zfb" {
payType = "alipay"
}
if req.PaymentMethod == "wx" {
req.PaymentMethod = "wxpay"
payType = "wxpay"
if !setting.ContainsPayMethod(req.PaymentMethod) {
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
return
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
@@ -116,7 +114,7 @@ func RequestEpay(c *gin.Context) {
return
}
uri, params, err := client.Purchase(&epay.PurchaseArgs{
Type: payType,
Type: req.PaymentMethod,
ServiceTradeNo: tradeNo,
Name: fmt.Sprintf("TUC%d", req.Amount),
Money: strconv.FormatFloat(payMoney, 'f', 2, 64),

View File

@@ -15,6 +15,7 @@ type ImageRequest struct {
Background string `json:"background,omitempty"`
Moderation string `json:"moderation,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
}
type ImageResponse struct {

View File

@@ -57,6 +57,7 @@ type GeneralOpenAIRequest struct {
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`
Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`

View File

@@ -26,7 +26,7 @@ type OpenAITextResponse struct {
Id string `json:"id"`
Model string `json:"model"`
Object string `json:"object"`
Created int64 `json:"created"`
Created any `json:"created"`
Choices []OpenAITextResponseChoice `json:"choices"`
Error *OpenAIError `json:"error,omitempty"`
Usage `json:"usage"`
@@ -178,6 +178,8 @@ type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// OpenRouter Params
Cost float64 `json:"cost,omitempty"`
}
type InputTokenDetails struct {

38
dto/ratio_sync.go Normal file
View File

@@ -0,0 +1,38 @@
package dto
type UpstreamDTO struct {
ID int `json:"id,omitempty"`
Name string `json:"name" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
Endpoint string `json:"endpoint"`
}
type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
}
// TestResult 上游测试连通性结果
type TestResult struct {
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// DifferenceItem 差异项
// Current 为本地值,可能为 nil
// Upstreams 为各渠道的上游值,具体数值 / "same" / nil
type DifferenceItem struct {
Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
}
type SyncableChannel struct {
ID int `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Status int `json:"status"`
}

47
dto/video.go Normal file
View File

@@ -0,0 +1,47 @@
package dto
type VideoRequest struct {
Model string `json:"model,omitempty" example:"kling-v1"` // Model/style ID
Prompt string `json:"prompt,omitempty" example:"宇航员站起身走了"` // Text prompt
Image string `json:"image,omitempty" 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"` // Image input (URL/Base64)
Duration float64 `json:"duration" example:"5.0"` // Video duration (seconds)
Width int `json:"width" example:"512"` // Video width
Height int `json:"height" example:"512"` // Video height
Fps int `json:"fps,omitempty" example:"30"` // Video frame rate
Seed int `json:"seed,omitempty" example:"20231234"` // Random seed
N int `json:"n,omitempty" example:"1"` // Number of videos to generate
ResponseFormat string `json:"response_format,omitempty" example:"url"` // Response format
User string `json:"user,omitempty" example:"user-1234"` // User identifier
Metadata map[string]any `json:"metadata,omitempty"` // Vendor-specific/custom params (e.g. negative_prompt, style, quality_level, etc.)
}
// VideoResponse 视频生成提交任务后的响应
type VideoResponse struct {
TaskId string `json:"task_id"`
Status string `json:"status"`
}
// VideoTaskResponse 查询视频生成任务状态的响应
type VideoTaskResponse struct {
TaskId string `json:"task_id" example:"abcd1234efgh"` // 任务ID
Status string `json:"status" example:"succeeded"` // 任务状态
Url string `json:"url,omitempty"` // 视频资源URL成功时
Format string `json:"format,omitempty" example:"mp4"` // 视频格式
Metadata *VideoTaskMetadata `json:"metadata,omitempty"` // 结果元数据
Error *VideoTaskError `json:"error,omitempty"` // 错误信息(失败时)
}
// VideoTaskMetadata 视频任务元数据
type VideoTaskMetadata struct {
Duration float64 `json:"duration" example:"5.0"` // 实际生成的视频时长
Fps int `json:"fps" example:"30"` // 实际帧率
Width int `json:"width" example:"512"` // 实际宽度
Height int `json:"height" example:"512"` // 实际高度
Seed int `json:"seed" example:"20231234"` // 使用的随机种子
}
// VideoTaskError 视频任务错误信息
type VideoTaskError struct {
Code int `json:"code"`
Message string `json:"message"`
}

View File

@@ -57,7 +57,7 @@ func Distribute() func(c *gin.Context) {
}
userGroup = tokenGroup
}
c.Set("group", userGroup)
c.Set(constant.ContextKeyUsingGroup, userGroup)
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
@@ -170,6 +170,15 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
}
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
relayMode := relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
if relayMode == relayconstant.RelayModeKlingFetchByID {
shouldSelectChannel = false
} else {
err = common.UnmarshalBodyReusable(c, &modelRequest)
}
c.Set("platform", string(constant.TaskPlatformKling))
c.Set("relay_mode", relayMode)
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
relayMode := relayconstant.RelayModeGemini

View File

@@ -127,6 +127,7 @@ func InitOptionMap() {
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
// 自动添加所有注册的模型配置
modelConfigs := config.GlobalConfig.ExportAllConfigs()
@@ -267,6 +268,8 @@ func updateOptionMap(key string, value string) (err error) {
setting.WorkerAllowHttpImageRequestEnabled = boolValue
case "DefaultUseAutoGroup":
setting.DefaultUseAutoGroup = boolValue
case "ExposeRatioEnabled":
ratio_setting.SetExposeRatioEnabled(boolValue)
}
}
switch key {

View File

@@ -327,3 +327,37 @@ func CountUserTokens(userId int) (int64, error) {
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
return total, err
}
// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量
func BatchDeleteTokens(ids []int, userId int) (int, error) {
if len(ids) == 0 {
return 0, errors.New("ids 不能为空!")
}
tx := DB.Begin()
var tokens []Token
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
if common.RedisEnabled {
gopool.Go(func() {
for _, t := range tokens {
_ = cacheDeleteToken(t.Key)
}
})
}
return len(tokens), nil
}

View File

@@ -55,7 +55,7 @@ func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
}
func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
relayInfo := relaycommon.GenRelayInfo(c)
relayInfo := relaycommon.GenRelayInfoOpenAIAudio(c)
audioRequest, err := getAndValidAudioRequest(c, relayInfo)
if err != nil {
@@ -66,10 +66,7 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
promptTokens := 0
preConsumedTokens := common.PreConsumedQuota
if relayInfo.RelayMode == relayconstant.RelayModeAudioSpeech {
promptTokens, err = service.CountTTSToken(audioRequest.Input, audioRequest.Model)
if err != nil {
return service.OpenAIErrorWrapper(err, "count_audio_token_failed", http.StatusInternalServerError)
}
promptTokens = service.CountTTSToken(audioRequest.Input, audioRequest.Model)
preConsumedTokens = promptTokens
relayInfo.PromptTokens = promptTokens
}
@@ -89,13 +86,11 @@ func AudioHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
}
}()
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, audioRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
audioRequest.Model = relayInfo.UpstreamModelName
adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil {
return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest)

View File

@@ -44,4 +44,6 @@ type TaskAdaptor interface {
// FetchTask
FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error)
ParseResultUrl(resp map[string]any) (string, error)
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"one-api/common"
"one-api/dto"
"one-api/relay/channel/openrouter"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
@@ -122,6 +123,21 @@ func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.Cla
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
}
if textRequest.Reasoning != nil {
var reasoning openrouter.RequestReasoning
if err := common.DecodeJson(textRequest.Reasoning, &reasoning); err != nil {
return nil, err
}
budgetTokens := reasoning.MaxTokens
if budgetTokens > 0 {
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: &budgetTokens,
}
}
}
if textRequest.Stop != nil {
// stop maybe string/array string, convert to array string
switch textRequest.Stop.(type) {
@@ -549,7 +565,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) {
if requestMode == RequestModeCompletion {
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
@@ -558,7 +574,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysError("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage, _ = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
@@ -618,10 +634,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
}
}
if requestMode == RequestModeCompletion {
completionTokens, err := service.CountTextToken(claudeResponse.Completion, info.OriginModelName)
if err != nil {
return service.OpenAIErrorWrapper(err, "count_token_text_failed", http.StatusInternalServerError)
}
completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName)
claudeInfo.Usage.PromptTokens = info.PromptTokens
claudeInfo.Usage.CompletionTokens = completionTokens
claudeInfo.Usage.TotalTokens = info.PromptTokens + completionTokens

View File

@@ -71,7 +71,7 @@ func cfStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
if err := scanner.Err(); err != nil {
common.LogError(c, "error_scanning_stream_response: "+err.Error())
}
usage, _ := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage)
err := helper.ObjectData(c, response)
@@ -108,7 +108,7 @@ func cfHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo)
for _, choice := range response.Choices {
responseText += choice.Message.StringContent()
}
usage, _ := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
response.Usage = *usage
response.Id = helper.GetResponseID(c)
jsonResponse, err := json.Marshal(response)
@@ -150,7 +150,7 @@ func cfSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayIn
usage := &dto.Usage{}
usage.PromptTokens = info.PromptTokens
usage.CompletionTokens, _ = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName)
usage.CompletionTokens = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName)
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return nil, usage

View File

@@ -162,7 +162,7 @@ func cohereStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
}
})
if usage.PromptTokens == 0 {
usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
return nil, usage
}

View File

@@ -106,7 +106,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
var currentEvent string
var currentData string
var usage dto.Usage
var usage = &dto.Usage{}
for scanner.Scan() {
line := scanner.Text()
@@ -114,7 +114,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
if line == "" {
if currentEvent != "" && currentData != "" {
// handle last event
handleCozeEvent(c, currentEvent, currentData, &responseText, &usage, id, info)
handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info)
currentEvent = ""
currentData = ""
}
@@ -134,7 +134,7 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
// Last event
if currentEvent != "" && currentData != "" {
handleCozeEvent(c, currentEvent, currentData, &responseText, &usage, id, info)
handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info)
}
if err := scanner.Err(); err != nil {
@@ -143,12 +143,10 @@ func cozeChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycommo
helper.Done(c)
if usage.TotalTokens == 0 {
usage.PromptTokens = info.PromptTokens
usage.CompletionTokens, _ = service.CountTextToken("gpt-3.5-turbo", responseText)
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
}
return nil, &usage
return nil, usage
}
func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) {

View File

@@ -243,15 +243,8 @@ func difyStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
return true
})
helper.Done(c)
err := resp.Body.Close()
if err != nil {
// return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
common.SysError("close_response_body_failed: " + err.Error())
}
if usage.TotalTokens == 0 {
usage.PromptTokens = info.PromptTokens
usage.CompletionTokens, _ = service.CountTextToken("gpt-3.5-turbo", responseText)
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
usage.CompletionTokens += nodeToken
return nil, usage

View File

@@ -73,12 +73,12 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.OriginModelName, "-thinking-") {
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.OriginModelName, "-thinking") { // 旧的适配
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
}
}

View File

@@ -9,6 +9,7 @@ import (
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"strings"
"github.com/gin-gonic/gin"
)
@@ -75,6 +76,8 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
helper.SetEventStreamHeaders(c)
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
var geminiResponse GeminiChatResponse
err := common.DecodeJsonStr(data, &geminiResponse)
@@ -89,6 +92,9 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
if part.Text != "" {
responseText.WriteString(part.Text)
}
}
}
@@ -108,7 +114,7 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
}
// 直接发送 GeminiChatResponse 响应
err = helper.ObjectData(c, geminiResponse)
err = helper.StringData(c, data)
if err != nil {
common.LogError(c, err.Error())
}
@@ -122,8 +128,16 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
}
}
// 计算最终使用量
// usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 如果usage.CompletionTokens为0则使用本地统计的completion tokens
if usage.CompletionTokens == 0 {
str := responseText.String()
if len(str) > 0 {
usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
// 空补全,不需要使用量
usage = &dto.Usage{}
}
}
// 移除流式响应结尾的[Done]因为Gemini API没有发送Done的行为
//helper.Done(c)

View File

@@ -78,32 +78,12 @@ func clampThinkingBudget(modelName string, budget int) int {
return budget
}
// Setting safety to the lowest possible values since Gemini is already powerless enough
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
geminiRequest := GeminiChatRequest{
Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
TopP: textRequest.TopP,
MaxOutputTokens: textRequest.MaxTokens,
Seed: int64(textRequest.Seed),
},
}
if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{
"TEXT",
"IMAGE",
}
}
func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
modelName := info.OriginModelName
modelName := info.UpstreamModelName
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
if strings.Contains(modelName, "-thinking-") {
parts := strings.SplitN(modelName, "-thinking-", 2)
@@ -134,21 +114,46 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(clampedBudget),
IncludeThoughts: true,
}
if geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget)
}
}
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro && !is25FlashLite {
if !isNew25Pro {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
}
}
}
}
// Setting safety to the lowest possible values since Gemini is already powerless enough
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
geminiRequest := GeminiChatRequest{
Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)),
GenerationConfig: GeminiChatGenerationConfig{
Temperature: textRequest.Temperature,
TopP: textRequest.TopP,
MaxOutputTokens: textRequest.MaxTokens,
Seed: int64(textRequest.Seed),
},
}
if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
geminiRequest.GenerationConfig.ResponseModalities = []string{
"TEXT",
"IMAGE",
}
}
ThinkingAdaptor(&geminiRequest, info)
safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList))
for _, category := range SafetySettingList {

View File

@@ -159,6 +159,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if info.ChannelType != common.ChannelTypeOpenAI && info.ChannelType != common.ChannelTypeAzure {
request.StreamOptions = nil
}
if info.ChannelType == common.ChannelTypeOpenRouter {
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
}
if strings.HasPrefix(request.Model, "o") {
if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 {
request.MaxCompletionTokens = request.MaxTokens

View File

@@ -8,7 +8,6 @@ import (
"math"
"mime/multipart"
"net/http"
"path/filepath"
"one-api/common"
"one-api/constant"
"one-api/dto"
@@ -16,6 +15,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"os"
"path/filepath"
"strings"
"github.com/bytedance/gopkg/util/gopool"
@@ -181,7 +181,7 @@ func OaiStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
}
if !containStreamUsage {
usage, _ = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
usage.CompletionTokens += toolCount * 7
} else {
if info.ChannelType == common.ChannelTypeDeepSeek {
@@ -216,7 +216,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
StatusCode: resp.StatusCode,
}, nil
}
forceFormat := false
if forceFmt, ok := info.ChannelSetting[constant.ForceFormat].(bool); ok {
forceFormat = forceFmt
@@ -225,7 +225,7 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
if simpleResponse.Usage.TotalTokens == 0 || (simpleResponse.Usage.PromptTokens == 0 && simpleResponse.Usage.CompletionTokens == 0) {
completionTokens := 0
for _, choice := range simpleResponse.Choices {
ctkm, _ := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName)
completionTokens += ctkm
}
simpleResponse.Usage = dto.Usage{
@@ -276,9 +276,9 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
// the status code has been judged before, if there is a body reading failure,
// it should be regarded as a non-recoverable error, so it should not return err for external retry.
// Analogous to nginx's load balancing, it will only retry if it can't be requested or
// if the upstream returns a specific status code, once the upstream has already written the header,
// the subsequent failure of the response body should be regarded as a non-recoverable error,
// Analogous to nginx's load balancing, it will only retry if it can't be requested or
// if the upstream returns a specific status code, once the upstream has already written the header,
// the subsequent failure of the response body should be regarded as a non-recoverable error,
// and can be terminated directly.
defer resp.Body.Close()
usage := &dto.Usage{}
@@ -346,12 +346,12 @@ func countAudioTokens(c *gin.Context) (int, error) {
if err = c.ShouldBind(&reqBody); err != nil {
return 0, errors.WithStack(err)
}
ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名
ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名
reqFp, err := reqBody.File.Open()
if err != nil {
return 0, errors.WithStack(err)
}
defer reqFp.Close()
defer reqFp.Close()
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {

View File

@@ -110,7 +110,7 @@ func OaiResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relayc
tempStr := responseTextBuilder.String()
if len(tempStr) > 0 {
// 非正常结束,使用输出文本的 token 数量
completionTokens, _ := service.CountTextToken(tempStr, info.UpstreamModelName)
completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName)
usage.CompletionTokens = completionTokens
}
}

View File

@@ -74,7 +74,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.IsStream {
var responseText string
err, responseText = palmStreamHandler(c, resp)
usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
} else {
err, usage = palmHandler(c, resp, info.PromptTokens, info.UpstreamModelName)
}

View File

@@ -155,7 +155,7 @@ func palmHandler(c *gin.Context, resp *http.Response, promptTokens int, model st
}, nil
}
fullTextResponse := responsePaLM2OpenAI(&palmResponse)
completionTokens, _ := service.CountTextToken(palmResponse.Candidates[0].Content, model)
completionTokens := service.CountTextToken(palmResponse.Candidates[0].Content, model)
usage := dto.Usage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,

View File

@@ -0,0 +1,312 @@
package kling
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt"
"github.com/pkg/errors"
"one-api/common"
"one-api/dto"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/service"
)
// ============================
// Request / Response structures
// ============================
type SubmitReq struct {
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Image string `json:"image,omitempty"`
Size string `json:"size,omitempty"`
Duration int `json:"duration,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type requestPayload struct {
Prompt string `json:"prompt,omitempty"`
Image string `json:"image,omitempty"`
Mode string `json:"mode,omitempty"`
Duration string `json:"duration,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Model string `json:"model,omitempty"`
ModelName string `json:"model_name,omitempty"`
CfgScale float64 `json:"cfg_scale,omitempty"`
}
type responsePayload struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
}
// ============================
// Adaptor implementation
// ============================
type TaskAdaptor struct {
ChannelType int
accessKey string
secretKey string
baseURL string
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
a.ChannelType = info.ChannelType
a.baseURL = info.BaseUrl
// apiKey format: "access_key|secret_key"
keyParts := strings.Split(info.ApiKey, "|")
if len(keyParts) == 2 {
a.accessKey = strings.TrimSpace(keyParts[0])
a.secretKey = strings.TrimSpace(keyParts[1])
}
}
// ValidateRequestAndSetAction parses body, validates fields and sets default action.
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
action := "generate"
info.Action = action
var req SubmitReq
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Prompt) == "" {
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest)
return
}
// Store into context for later usage
c.Set("kling_request", req)
return nil
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) {
return fmt.Sprintf("%s/v1/videos/image2video", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error {
token, err := a.createJWTToken()
if err != nil {
return fmt.Errorf("failed to create JWT token: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "kling-sdk/1.0")
return nil
}
// BuildRequestBody converts request into Kling specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) {
v, exists := c.Get("kling_request")
if !exists {
return nil, fmt.Errorf("request not found in context")
}
req := v.(SubmitReq)
body := a.convertToRequestPayload(&req)
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bytes.NewReader(data), nil
}
// DoRequest delegates to common helper.
func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) {
return channel.DoTaskApiRequest(a, c, info, requestBody)
}
// DoResponse handles upstream response, returns taskID etc.
func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
return
}
// Attempt Kling response parse first.
var kResp responsePayload
if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 {
c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskID})
return kResp.Data.TaskID, responseBody, nil
}
// Fallback generic task response.
var generic dto.TaskResponse[string]
if err := json.Unmarshal(responseBody, &generic); err != nil {
taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError)
return
}
if !generic.IsSuccess() {
taskErr = service.TaskErrorWrapper(fmt.Errorf(generic.Message), generic.Code, http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"task_id": generic.Data})
return generic.Data, responseBody, nil
}
// FetchTask fetch task status
func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) {
taskID, ok := body["task_id"].(string)
if !ok {
return nil, fmt.Errorf("invalid task_id")
}
url := fmt.Sprintf("%s/v1/videos/image2video/%s", baseUrl, taskID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
token, err := a.createJWTTokenWithKey(key)
if err != nil {
token = key
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req = req.WithContext(ctx)
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "kling-sdk/1.0")
return service.GetHttpClient().Do(req)
}
func (a *TaskAdaptor) GetModelList() []string {
return []string{"kling-v1", "kling-v1-6", "kling-v2-master"}
}
func (a *TaskAdaptor) GetChannelName() string {
return "kling"
}
// ============================
// helpers
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) *requestPayload {
r := &requestPayload{
Prompt: req.Prompt,
Image: req.Image,
Mode: defaultString(req.Mode, "std"),
Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)),
AspectRatio: a.getAspectRatio(req.Size),
Model: req.Model,
ModelName: req.Model,
CfgScale: 0.5,
}
if r.Model == "" {
r.Model = "kling-v1"
r.ModelName = "kling-v1"
}
return r
}
func (a *TaskAdaptor) getAspectRatio(size string) string {
switch size {
case "1024x1024", "512x512":
return "1:1"
case "1280x720", "1920x1080":
return "16:9"
case "720x1280", "1080x1920":
return "9:16"
default:
return "1:1"
}
}
func defaultString(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func defaultInt(v int, def int) int {
if v == 0 {
return def
}
return v
}
// ============================
// JWT helpers
// ============================
func (a *TaskAdaptor) createJWTToken() (string, error) {
return a.createJWTTokenWithKeys(a.accessKey, a.secretKey)
}
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
parts := strings.Split(apiKey, "|")
if len(parts) != 2 {
return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
}
return a.createJWTTokenWithKeys(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
}
func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (string, error) {
if accessKey == "" || secretKey == "" {
return "", fmt.Errorf("access key and secret key are required")
}
now := time.Now().Unix()
claims := jwt.MapClaims{
"iss": accessKey,
"exp": now + 1800, // 30 minutes
"nbf": now - 5,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["typ"] = "JWT"
return token.SignedString([]byte(secretKey))
}
// ParseResultUrl 提取视频任务结果的 url
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
data, ok := resp["data"].(map[string]any)
if !ok {
return "", fmt.Errorf("data field not found or invalid")
}
taskResult, ok := data["task_result"].(map[string]any)
if !ok {
return "", fmt.Errorf("task_result field not found or invalid")
}
videos, ok := taskResult["videos"].([]interface{})
if !ok || len(videos) == 0 {
return "", fmt.Errorf("videos field not found or empty")
}
video, ok := videos[0].(map[string]interface{})
if !ok {
return "", fmt.Errorf("video item invalid")
}
url, ok := video["url"].(string)
if !ok || url == "" {
return "", fmt.Errorf("url field not found or invalid")
}
return url, nil
}

View File

@@ -22,6 +22,10 @@ type TaskAdaptor struct {
ChannelType int
}
func (a *TaskAdaptor) ParseResultUrl(resp map[string]any) (string, error) {
return "", nil // todo implement this method if needed
}
func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) {
a.ChannelType = info.ChannelType
}

View File

@@ -98,7 +98,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.IsStream {
var responseText string
err, responseText = tencentStreamHandler(c, resp)
usage, _ = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
} else {
err, usage = tencentHandler(c, resp)
}

View File

@@ -83,10 +83,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// suffix -thinking and -nothinking
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
}
}

View File

@@ -1,15 +1,19 @@
package volcengine
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
@@ -30,8 +34,146 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
//TODO implement me
return nil, errors.New("not implemented")
switch info.RelayMode {
case constant.RelayModeImagesEdits:
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
writer.WriteField("model", request.Model)
// 获取所有表单字段
formData := c.Request.PostForm
// 遍历表单字段并打印输出
for key, values := range formData {
if key == "model" {
continue
}
for _, value := range values {
writer.WriteField(key, value)
}
}
// Parse the multipart form to handle both single image and multiple images
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory
return nil, errors.New("failed to parse multipart form")
}
if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
// Check if "image" field exists in any form, including array notation
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range c.Request.MultipartForm.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
for _, file := range files {
imageFiles = append(imageFiles, file)
}
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
// Process all image files
for i, fileHeader := range imageFiles {
file, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
}
defer file.Close()
// If multiple images, use image[] as the field name
fieldName := "image"
if len(imageFiles) > 1 {
fieldName = "image[]"
}
// Determine MIME type based on file extension
mimeType := detectImageMimeType(fileHeader.Filename)
// Create a form file with the appropriate content type
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
h.Set("Content-Type", mimeType)
part, err := writer.CreatePart(h)
if err != nil {
return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
}
}
// Handle mask file if present
if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 {
maskFile, err := maskFiles[0].Open()
if err != nil {
return nil, errors.New("failed to open mask file")
}
defer maskFile.Close()
// Determine MIME type for mask file
mimeType := detectImageMimeType(maskFiles[0].Filename)
// Create a form file with the appropriate content type
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
h.Set("Content-Type", mimeType)
maskPart, err := writer.CreatePart(h)
if err != nil {
return nil, errors.New("create form file failed for mask")
}
if _, err := io.Copy(maskPart, maskFile); err != nil {
return nil, errors.New("copy mask file failed")
}
}
} else {
return nil, errors.New("no multipart form data found")
}
// 关闭 multipart 编写器以设置分界线
writer.Close()
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
return bytes.NewReader(requestBody.Bytes()), nil
default:
return request, nil
}
}
// detectImageMimeType determines the MIME type based on the file extension
func detectImageMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
default:
// Try to detect from extension if possible
if strings.HasPrefix(ext, ".jp") {
return "image/jpeg"
}
// Default to png as a fallback
return "image/png"
}
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -46,6 +188,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/chat/completions", info.BaseUrl), nil
case constant.RelayModeEmbeddings:
return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil
default:
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
@@ -91,6 +235,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
}
case constant.RelayModeEmbeddings:
err, usage = openai.OpenaiHandler(c, resp, info)
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
err, usage = openai.OpenaiHandlerWithUsage(c, resp, info)
}
return
}

View File

@@ -68,7 +68,7 @@ func xAIStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
})
if !containStreamUsage {
usage, _ = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens)
usage.CompletionTokens += toolCount * 7
}

View File

@@ -46,13 +46,11 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) {
relayInfo.IsStream = true
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, textRequest)
if err != nil {
return service.ClaudeErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
textRequest.Model = relayInfo.UpstreamModelName
promptTokens, err := getClaudePromptTokens(textRequest, relayInfo)
// count messages token error 计算promptTokens错误
if err != nil {
@@ -126,7 +124,7 @@ func ClaudeHelper(c *gin.Context) (claudeError *dto.ClaudeErrorWithStatusCode) {
var httpResp *http.Response
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
if err != nil {
return service.ClaudeErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError)
return service.ClaudeErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
if resp != nil {

View File

@@ -34,9 +34,14 @@ type ClaudeConvertInfo struct {
}
const (
RelayFormatOpenAI = "openai"
RelayFormatClaude = "claude"
RelayFormatGemini = "gemini"
RelayFormatOpenAI = "openai"
RelayFormatClaude = "claude"
RelayFormatGemini = "gemini"
RelayFormatOpenAIResponses = "openai_responses"
RelayFormatOpenAIAudio = "openai_audio"
RelayFormatOpenAIImage = "openai_image"
RelayFormatRerank = "rerank"
RelayFormatEmbedding = "embedding"
)
type RerankerInfo struct {
@@ -60,8 +65,8 @@ type RelayInfo struct {
TokenId int
TokenKey string
UserId int
Group string
UserGroup string
UsingGroup string // 使用的分组
UserGroup string // 用户所在分组
TokenUnlimited bool
StartTime time.Time
FirstResponseTime time.Time
@@ -143,6 +148,7 @@ func GenRelayInfoClaude(c *gin.Context) *RelayInfo {
func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo {
info := GenRelayInfo(c)
info.RelayMode = relayconstant.RelayModeRerank
info.RelayFormat = RelayFormatRerank
info.RerankerInfo = &RerankerInfo{
Documents: req.Documents,
ReturnDocuments: req.GetReturnDocuments(),
@@ -150,9 +156,25 @@ func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo {
return info
}
func GenRelayInfoOpenAIAudio(c *gin.Context) *RelayInfo {
info := GenRelayInfo(c)
info.RelayFormat = RelayFormatOpenAIAudio
return info
}
func GenRelayInfoEmbedding(c *gin.Context) *RelayInfo {
info := GenRelayInfo(c)
info.RelayFormat = RelayFormatEmbedding
return info
}
func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *RelayInfo {
info := GenRelayInfo(c)
info.RelayMode = relayconstant.RelayModeResponses
info.RelayFormat = RelayFormatOpenAIResponses
info.SupportStreamOptions = false
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
@@ -175,6 +197,19 @@ func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *Rel
return info
}
func GenRelayInfoGemini(c *gin.Context) *RelayInfo {
info := GenRelayInfo(c)
info.RelayFormat = RelayFormatGemini
info.ShouldIncludeUsage = false
return info
}
func GenRelayInfoImage(c *gin.Context) *RelayInfo {
info := GenRelayInfo(c)
info.RelayFormat = RelayFormatOpenAIImage
return info
}
func GenRelayInfo(c *gin.Context) *RelayInfo {
channelType := c.GetInt("channel_type")
channelId := c.GetInt("channel_id")
@@ -184,7 +219,6 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
tokenId := c.GetInt("token_id")
tokenKey := c.GetString("token_key")
userId := c.GetInt("id")
group := c.GetString("group")
tokenUnlimited := c.GetBool("token_unlimited_quota")
startTime := c.GetTime(constant.ContextKeyRequestStartTime)
// firstResponseTime = time.Now() - 1 second
@@ -204,7 +238,7 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
TokenId: tokenId,
TokenKey: tokenKey,
UserId: userId,
Group: group,
UsingGroup: c.GetString(constant.ContextKeyUsingGroup),
UserGroup: c.GetString(constant.ContextKeyUserGroup),
TokenUnlimited: tokenUnlimited,
StartTime: startTime,
@@ -243,10 +277,6 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
if streamSupportedChannels[info.ChannelType] {
info.SupportStreamOptions = true
}
// responses 模式不支持 StreamOptions
if relayconstant.RelayModeResponses == info.RelayMode {
info.SupportStreamOptions = false
}
return info
}

View File

@@ -38,6 +38,9 @@ const (
RelayModeSunoFetchByID
RelayModeSunoSubmit
RelayModeKlingFetchByID
RelayModeKlingSubmit
RelayModeRerank
RelayModeResponses
@@ -133,3 +136,13 @@ func Path2RelaySuno(method, path string) int {
}
return relayMode
}
func Path2RelayKling(method, path string) int {
relayMode := RelayModeUnknown
if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") {
relayMode = RelayModeKlingSubmit
} else if method == http.MethodGet && strings.Contains(path, "/video/generations/") {
relayMode = RelayModeKlingFetchByID
}
return relayMode
}

View File

@@ -15,7 +15,7 @@ import (
)
func getEmbeddingPromptToken(embeddingRequest dto.EmbeddingRequest) int {
token, _ := service.CountTokenInput(embeddingRequest.Input, embeddingRequest.Model)
token := service.CountTokenInput(embeddingRequest.Input, embeddingRequest.Model)
return token
}
@@ -33,7 +33,7 @@ func validateEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, embed
}
func EmbeddingHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
relayInfo := relaycommon.GenRelayInfo(c)
relayInfo := relaycommon.GenRelayInfoEmbedding(c)
var embeddingRequest *dto.EmbeddingRequest
err := common.UnmarshalBodyReusable(c, &embeddingRequest)
@@ -47,13 +47,11 @@ func EmbeddingHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode)
return service.OpenAIErrorWrapperLocal(err, "invalid_embedding_request", http.StatusBadRequest)
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, embeddingRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
embeddingRequest.Model = relayInfo.UpstreamModelName
promptToken := getEmbeddingPromptToken(*embeddingRequest)
relayInfo.PromptTokens = promptToken

View File

@@ -13,6 +13,7 @@ import (
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"one-api/setting/model_setting"
"strings"
"github.com/gin-gonic/gin"
@@ -59,7 +60,7 @@ func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string,
return sensitiveWords, err
}
func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) (int, error) {
func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int {
// 计算输入 token 数量
var inputTexts []string
for _, content := range req.Contents {
@@ -71,9 +72,36 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay
}
inputText := strings.Join(inputTexts, "\n")
inputTokens, err := service.CountTokenInput(inputText, info.UpstreamModelName)
inputTokens := service.CountTokenInput(inputText, info.UpstreamModelName)
info.PromptTokens = inputTokens
return inputTokens, err
return inputTokens
}
func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool {
if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0
}
return false
}
func trimModelThinking(modelName string) string {
// 去除模型名称中的 -nothinking 后缀
if strings.HasSuffix(modelName, "-nothinking") {
return strings.TrimSuffix(modelName, "-nothinking")
}
// 去除模型名称中的 -thinking 后缀
if strings.HasSuffix(modelName, "-thinking") {
return strings.TrimSuffix(modelName, "-thinking")
}
// 去除模型名称中的 -thinking-number
if strings.Contains(modelName, "-thinking-") {
parts := strings.Split(modelName, "-thinking-")
if len(parts) > 1 {
return parts[0] + "-thinking"
}
}
return modelName
}
func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
@@ -83,7 +111,7 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
return service.OpenAIErrorWrapperLocal(err, "invalid_gemini_request", http.StatusBadRequest)
}
relayInfo := relaycommon.GenRelayInfo(c)
relayInfo := relaycommon.GenRelayInfoGemini(c)
// 检查 Gemini 流式模式
checkGeminiStreamMode(c, relayInfo)
@@ -97,7 +125,7 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
}
// model mapped 模型映射
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, req)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest)
}
@@ -106,13 +134,28 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
promptTokens := value.(int)
relayInfo.SetPromptTokens(promptTokens)
} else {
promptTokens, err := getGeminiInputTokens(req, relayInfo)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
}
promptTokens := getGeminiInputTokens(req, relayInfo)
c.Set("prompt_tokens", promptTokens)
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if isNoThinkingRequest(req) {
// check is thinking
if !strings.Contains(relayInfo.OriginModelName, "-nothinking") {
// try to get no thinking model price
noThinkingModelName := relayInfo.OriginModelName + "-nothinking"
containPrice := helper.ContainPriceOrRatio(noThinkingModelName)
if containPrice {
relayInfo.OriginModelName = noThinkingModelName
relayInfo.UpstreamModelName = noThinkingModelName
}
}
}
if req.GenerationConfig.ThinkingConfig == nil {
gemini.ThinkingAdaptor(req, relayInfo)
}
}
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens))
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
@@ -162,7 +205,7 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody))
if err != nil {
common.LogError(c, "Do gemini request failed: "+err.Error())
return service.OpenAIErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError)
return service.OpenAIErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")

View File

@@ -4,12 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
common2 "one-api/common"
"one-api/dto"
"one-api/relay/common"
"github.com/gin-gonic/gin"
)
func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error {
func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request any) error {
// map model name
modelMapping := c.GetString("model_mapping")
if modelMapping != "" && modelMapping != "{}" {
@@ -50,5 +52,41 @@ func ModelMappedHelper(c *gin.Context, info *common.RelayInfo) error {
info.UpstreamModelName = currentModel
}
}
if request != nil {
switch info.RelayFormat {
case common.RelayFormatGemini:
// Gemini 模型映射
case common.RelayFormatClaude:
if claudeRequest, ok := request.(*dto.ClaudeRequest); ok {
claudeRequest.Model = info.UpstreamModelName
}
case common.RelayFormatOpenAIResponses:
if openAIResponsesRequest, ok := request.(*dto.OpenAIResponsesRequest); ok {
openAIResponsesRequest.Model = info.UpstreamModelName
}
case common.RelayFormatOpenAIAudio:
if openAIAudioRequest, ok := request.(*dto.AudioRequest); ok {
openAIAudioRequest.Model = info.UpstreamModelName
}
case common.RelayFormatOpenAIImage:
if imageRequest, ok := request.(*dto.ImageRequest); ok {
imageRequest.Model = info.UpstreamModelName
}
case common.RelayFormatRerank:
if rerankRequest, ok := request.(*dto.RerankRequest); ok {
rerankRequest.Model = info.UpstreamModelName
}
case common.RelayFormatEmbedding:
if embeddingRequest, ok := request.(*dto.EmbeddingRequest); ok {
embeddingRequest.Model = info.UpstreamModelName
}
default:
if openAIRequest, ok := request.(*dto.GeneralOpenAIRequest); ok {
openAIRequest.Model = info.UpstreamModelName
} else {
common2.LogWarn(c, fmt.Sprintf("model mapped but request type %T not supported", request))
}
}
}
return nil
}

View File

@@ -13,6 +13,7 @@ import (
type GroupRatioInfo struct {
GroupRatio float64
GroupSpecialRatio float64
HasSpecialRatio bool
}
type PriceData struct {
@@ -31,7 +32,7 @@ func (p PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
}
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.Group if present
// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present
func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo {
groupRatioInfo := GroupRatioInfo{
GroupRatio: 1.0, // default ratio
@@ -44,18 +45,19 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupR
if common.DebugEnabled {
println(fmt.Sprintf("final group: %s", autoGroup))
}
relayInfo.Group = autoGroup.(string)
relayInfo.UsingGroup = autoGroup.(string)
}
// check user group special ratio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if ok {
// user group special ratio
groupRatioInfo.GroupSpecialRatio = userGroupRatio
groupRatioInfo.GroupRatio = userGroupRatio
groupRatioInfo.HasSpecialRatio = true
} else {
// normal group ratio
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.Group)
groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
}
return groupRatioInfo
@@ -120,6 +122,35 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
type PerCallPriceData struct {
ModelPrice float64
Quota int
GroupRatioInfo GroupRatioInfo
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) PerCallPriceData {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,则使用默认价格
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[info.OriginModelName]
if !ok {
modelPrice = 0.1
} else {
modelPrice = defaultPrice
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
priceData := PerCallPriceData{
ModelPrice: modelPrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}
return priceData
}
func ContainPriceOrRatio(modelName string) bool {
_, ok := ratio_setting.GetModelPrice(modelName, false)
if ok {

View File

@@ -17,6 +17,8 @@ import (
"one-api/setting"
"strings"
"one-api/relay/constant"
"github.com/gin-gonic/gin"
)
@@ -44,6 +46,11 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
if imageRequest.N == 0 {
imageRequest.N = 1
}
if info.ApiType == constant.APITypeVolcEngine {
watermark := formData.Has("watermark")
imageRequest.Watermark = &watermark
}
default:
err := common.UnmarshalBodyReusable(c, imageRequest)
if err != nil {
@@ -102,7 +109,7 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
}
func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
relayInfo := relaycommon.GenRelayInfo(c)
relayInfo := relaycommon.GenRelayInfoImage(c)
imageRequest, err := getAndValidImageRequest(c, relayInfo)
if err != nil {
@@ -110,13 +117,11 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
return service.OpenAIErrorWrapper(err, "invalid_image_request", http.StatusBadRequest)
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, imageRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
imageRequest.Model = relayInfo.UpstreamModelName
priceData, err := helper.ModelPriceHelper(c, relayInfo, len(imageRequest.Prompt), 0)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)

View File

@@ -13,9 +13,9 @@ import (
"one-api/model"
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"one-api/setting/ratio_setting"
"strconv"
"strings"
"time"
@@ -174,18 +174,9 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required")
}
modelName := service.CoverActionToModelName(constant.MjActionSwapFace)
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
// 如果没有配置价格,则使用默认价格
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !ok {
modelPrice = 0.1
} else {
modelPrice = defaultPrice
}
}
groupRatio := ratio_setting.GetGroupRatio(group)
ratio := modelPrice * groupRatio
priceData := helper.ModelPriceHelperPerCall(c, relayInfo)
userQuota, err := model.GetUserQuota(userId, false)
if err != nil {
return &dto.MidjourneyResponse{
@@ -193,9 +184,8 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
Description: err.Error(),
}
}
quota := int(ratio * common.QuotaPerUnit)
if userQuota-quota < 0 {
if userQuota-priceData.Quota < 0 {
return &dto.MidjourneyResponse{
Code: 4,
Description: "quota_not_enough",
@@ -210,26 +200,18 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
}
defer func() {
if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 {
err := service.PostConsumeQuota(relayInfo, quota, 0, true)
err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true)
if err != nil {
common.SysError("error consuming token remain quota: " + err.Error())
}
//err = model.CacheUpdateUserQuota(userId)
if err != nil {
common.SysError("error update user quota cache: " + err.Error())
}
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, constant.MjActionSwapFace)
other := make(map[string]interface{})
other["model_price"] = modelPrice
other["group_ratio"] = groupRatio
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
quota, logContent, tokenId, userQuota, 0, false, group, other)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)
}
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace)
other := service.GenerateMjOtherInfo(priceData)
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other)
model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota)
model.UpdateChannelUsedQuota(channelId, priceData.Quota)
}
}()
midjResponse := &mjResp.Response
@@ -250,7 +232,7 @@ func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse {
Progress: "0%",
FailReason: "",
ChannelId: c.GetInt("channel_id"),
Quota: quota,
Quota: priceData.Quota,
}
err = midjourneyTask.Insert()
if err != nil {
@@ -480,18 +462,9 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
modelName := service.CoverActionToModelName(midjRequest.Action)
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
// 如果没有配置价格,则使用默认价格
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
if !ok {
modelPrice = 0.1
} else {
modelPrice = defaultPrice
}
}
groupRatio := ratio_setting.GetGroupRatio(group)
ratio := modelPrice * groupRatio
priceData := helper.ModelPriceHelperPerCall(c, relayInfo)
userQuota, err := model.GetUserQuota(userId, false)
if err != nil {
return &dto.MidjourneyResponse{
@@ -499,9 +472,8 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
Description: err.Error(),
}
}
quota := int(ratio * common.QuotaPerUnit)
if consumeQuota && userQuota-quota < 0 {
if consumeQuota && userQuota-priceData.Quota < 0 {
return &dto.MidjourneyResponse{
Code: 4,
Description: "quota_not_enough",
@@ -516,22 +488,17 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
defer func() {
if consumeQuota && midjResponseWithStatus.StatusCode == 200 {
err := service.PostConsumeQuota(relayInfo, quota, 0, true)
err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true)
if err != nil {
common.SysError("error consuming token remain quota: " + err.Error())
}
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %sID %s", modelPrice, groupRatio, midjRequest.Action, midjResponse.Result)
other := make(map[string]interface{})
other["model_price"] = modelPrice
other["group_ratio"] = groupRatio
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
quota, logContent, tokenId, userQuota, 0, false, group, other)
model.UpdateUserUsedQuotaAndRequestCount(userId, quota)
channelId := c.GetInt("channel_id")
model.UpdateChannelUsedQuota(channelId, quota)
}
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %sID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result)
other := service.GenerateMjOtherInfo(priceData)
model.RecordConsumeLog(c, userId, channelId, 0, 0, modelName, tokenName,
priceData.Quota, logContent, tokenId, userQuota, 0, false, group, other)
model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota)
model.UpdateChannelUsedQuota(channelId, priceData.Quota)
}
}()
@@ -559,7 +526,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyRespons
Progress: "0%",
FailReason: "",
ChannelId: c.GetInt("channel_id"),
Quota: quota,
Quota: priceData.Quota,
}
if midjResponse.Code == 3 {
//无实例账号自动禁用渠道No available account instance

View File

@@ -108,13 +108,11 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
}
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, textRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
textRequest.Model = relayInfo.UpstreamModelName
// 获取 promptTokens如果上下文中已经存在则直接使用
var promptTokens int
if value, exists := c.Get("prompt_tokens"); exists {
@@ -253,11 +251,11 @@ func getPromptTokens(textRequest *dto.GeneralOpenAIRequest, info *relaycommon.Re
case relayconstant.RelayModeChatCompletions:
promptTokens, err = service.CountTokenChatRequest(info, *textRequest)
case relayconstant.RelayModeCompletions:
promptTokens, err = service.CountTokenInput(textRequest.Prompt, textRequest.Model)
promptTokens = service.CountTokenInput(textRequest.Prompt, textRequest.Model)
case relayconstant.RelayModeModerations:
promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model)
promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model)
case relayconstant.RelayModeEmbeddings:
promptTokens, err = service.CountTokenInput(textRequest.Input, textRequest.Model)
promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model)
default:
err = errors.New("unknown relay mode")
promptTokens = 0
@@ -543,5 +541,5 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other["audio_input_price"] = audioInputPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
}

View File

@@ -22,6 +22,7 @@ import (
"one-api/relay/channel/palm"
"one-api/relay/channel/perplexity"
"one-api/relay/channel/siliconflow"
"one-api/relay/channel/task/kling"
"one-api/relay/channel/task/suno"
"one-api/relay/channel/tencent"
"one-api/relay/channel/vertex"
@@ -101,6 +102,8 @@ func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor {
// return &aiproxy.Adaptor{}
case commonconstant.TaskPlatformSuno:
return &suno.TaskAdaptor{}
case commonconstant.TaskPlatformKling:
return &kling.TaskAdaptor{}
}
return nil
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/common"
@@ -16,6 +15,8 @@ import (
relayconstant "one-api/relay/constant"
"one-api/service"
"one-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
/*
@@ -37,6 +38,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
modelName := service.CoverTaskActionToModelName(platform, relayInfo.Action)
if platform == constant.TaskPlatformKling {
modelName = relayInfo.OriginModelName
}
modelPrice, success := ratio_setting.GetModelPrice(modelName, true)
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName]
@@ -48,8 +52,14 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
// 预扣
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
ratio := modelPrice * groupRatio
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
var ratio float64
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if hasUserGroupRatio {
ratio = modelPrice * userGroupRatio
} else {
ratio = modelPrice * groupRatio
}
userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
if err != nil {
taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError)
@@ -118,12 +128,19 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
if quota != 0 {
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, groupRatio, relayInfo.Action)
gRatio := groupRatio
if hasUserGroupRatio {
gRatio = userGroupRatio
}
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action)
other := make(map[string]interface{})
other["model_price"] = modelPrice
other["group_ratio"] = groupRatio
if hasUserGroupRatio {
other["user_group_ratio"] = userGroupRatio
}
model.RecordConsumeLog(c, relayInfo.UserId, relayInfo.ChannelId, 0, 0,
modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.Group, other)
modelName, tokenName, quota, logContent, relayInfo.TokenId, userQuota, 0, false, relayInfo.UsingGroup, other)
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
}
@@ -136,10 +153,11 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
relayInfo.ConsumeQuota = true
// insert task
task := model.InitTask(constant.TaskPlatformSuno, relayInfo)
task := model.InitTask(platform, relayInfo)
task.TaskID = taskID
task.Quota = quota
task.Data = taskData
task.Action = relayInfo.Action
err = task.Insert()
if err != nil {
taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError)
@@ -149,8 +167,9 @@ func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) {
}
var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){
relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder,
relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder,
relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder,
relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder,
relayconstant.RelayModeKlingFetchByID: videoFetchByIDRespBodyBuilder,
}
func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) {
@@ -225,6 +244,27 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt
return
}
func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
taskId := c.Param("id")
userId := c.GetInt("id")
originTask, exist, err := model.GetByTaskId(userId, taskId)
if err != nil {
taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError)
return
}
if !exist {
taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest)
return
}
respBody, err = json.Marshal(dto.TaskResponse[any]{
Code: "success",
Data: TaskModel2Dto(originTask),
})
return
}
func TaskModel2Dto(task *model.Task) *dto.TaskDto {
return &dto.TaskDto{
TaskID: task.TaskID,

View File

@@ -14,12 +14,10 @@ import (
)
func getRerankPromptToken(rerankRequest dto.RerankRequest) int {
token, _ := service.CountTokenInput(rerankRequest.Query, rerankRequest.Model)
token := service.CountTokenInput(rerankRequest.Query, rerankRequest.Model)
for _, document := range rerankRequest.Documents {
tkm, err := service.CountTokenInput(document, rerankRequest.Model)
if err == nil {
token += tkm
}
tkm := service.CountTokenInput(document, rerankRequest.Model)
token += tkm
}
return token
}
@@ -42,13 +40,11 @@ func RerankHelper(c *gin.Context, relayMode int) (openaiErr *dto.OpenAIErrorWith
return service.OpenAIErrorWrapperLocal(fmt.Errorf("documents is empty"), "invalid_documents", http.StatusBadRequest)
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, rerankRequest)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusInternalServerError)
}
rerankRequest.Model = relayInfo.UpstreamModelName
promptToken := getRerankPromptToken(*rerankRequest)
relayInfo.PromptTokens = promptToken

View File

@@ -40,10 +40,10 @@ func checkInputSensitive(textRequest *dto.OpenAIResponsesRequest, info *relaycom
return sensitiveWords, err
}
func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (int, error) {
inputTokens, err := service.CountTokenInput(req.Input, req.Model)
func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) int {
inputTokens := service.CountTokenInput(req.Input, req.Model)
info.PromptTokens = inputTokens
return inputTokens, err
return inputTokens
}
func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
@@ -63,19 +63,16 @@ func ResponsesHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode)
}
}
err = helper.ModelMappedHelper(c, relayInfo)
err = helper.ModelMappedHelper(c, relayInfo, req)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest)
}
req.Model = relayInfo.UpstreamModelName
if value, exists := c.Get("prompt_tokens"); exists {
promptTokens := value.(int)
relayInfo.SetPromptTokens(promptTokens)
} else {
promptTokens, err := getInputTokens(req, relayInfo)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
}
promptTokens := getInputTokens(req, relayInfo)
c.Set("prompt_tokens", promptTokens)
}

View File

@@ -36,6 +36,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
userRoute := apiRouter.Group("/user")
{
@@ -83,6 +84,12 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}
ratioSyncRoute := apiRouter.Group("/ratio_sync")
ratioSyncRoute.Use(middleware.RootAuth())
{
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
{
@@ -118,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
tokenRoute.POST("/", controller.AddToken)
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
}
redemptionRoute := apiRouter.Group("/redemption")
redemptionRoute.Use(middleware.AdminAuth())

View File

@@ -14,6 +14,7 @@ func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router)
SetVideoRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""

17
router/video-router.go Normal file
View File

@@ -0,0 +1,17 @@
package router
import (
"one-api/controller"
"one-api/middleware"
"github.com/gin-gonic/gin"
)
func SetVideoRouter(router *gin.Engine) {
videoV1Router := router.Group("/v1")
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
videoV1Router.POST("/video/generations", controller.RelayTask)
videoV1Router.GET("/video/generations/:task_id", controller.RelayTask)
}
}

View File

@@ -276,12 +276,15 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
}
if info.Done {
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
if info.ClaudeConvertInfo.Usage != nil {
oaiUsage := info.ClaudeConvertInfo.Usage
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: info.ClaudeConvertInfo.Usage.PromptTokens,
OutputTokens: info.ClaudeConvertInfo.Usage.CompletionTokens,
InputTokens: oaiUsage.PromptTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
},
Delta: &dto.ClaudeMediaMessage{
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),

View File

@@ -3,6 +3,7 @@ package service
import (
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"github.com/gin-gonic/gin"
)
@@ -63,3 +64,13 @@ func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
info["cache_creation_ratio"] = cacheCreationRatio
return info
}
func GenerateMjOtherInfo(priceData helper.PerCallPriceData) map[string]interface{} {
other := make(map[string]interface{})
other["model_price"] = priceData.ModelPrice
other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio
if priceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio
}
return other
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log"
"math"
"one-api/common"
constant2 "one-api/constant"
"one-api/dto"
@@ -94,18 +95,18 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
textOutTokens := usage.OutputTokenDetails.TextTokens
audioInputTokens := usage.InputTokenDetails.AudioTokens
audioOutTokens := usage.OutputTokenDetails.AudioTokens
groupRatio := ratio_setting.GetGroupRatio(relayInfo.Group)
groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup)
modelRatio, _ := ratio_setting.GetModelRatio(modelName)
autoGroup, exists := ctx.Get("auto_group")
if exists {
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
log.Printf("final group ratio: %f", groupRatio)
relayInfo.Group = autoGroup.(string)
relayInfo.UsingGroup = autoGroup.(string)
}
actualGroupRatio := groupRatio
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.Group)
userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup)
if ok {
actualGroupRatio = userGroupRatio
}
@@ -209,7 +210,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.InputTokens, usage.OutputTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
}
func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
@@ -231,6 +232,17 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
cacheCreationRatio := priceData.CacheCreationRatio
cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens
if relayInfo.ChannelType == common.ChannelTypeOpenRouter {
promptTokens -= cacheTokens
if cacheCreationTokens == 0 && priceData.CacheCreationRatio != 1 && usage.Cost != 0 {
maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, priceData)
if promptTokens >= maybeCacheCreationTokens {
cacheCreationTokens = maybeCacheCreationTokens
}
}
promptTokens -= cacheCreationTokens
}
calculateQuota := 0.0
if !priceData.UsePrice {
calculateQuota = float64(promptTokens)
@@ -275,7 +287,28 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio,
cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, modelName,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
}
func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData) int {
if priceData.CacheCreationRatio == 1 {
return 0
}
quotaPrice := priceData.ModelRatio / common.QuotaPerUnit
promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio
promptCacheReadPrice := quotaPrice * priceData.CacheRatio
completionPrice := quotaPrice * priceData.CompletionRatio
cost := usage.Cost
totalPromptTokens := float64(usage.PromptTokens)
completionTokens := float64(usage.CompletionTokens)
promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens)
return int(math.Round((cost -
totalPromptTokens*quotaPrice +
promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) -
completionTokens*completionPrice) /
(promptCacheCreatePrice - quotaPrice)))
}
func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
@@ -352,7 +385,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio,
completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, usage.PromptTokens, usage.CompletionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.UsingGroup, other)
}
func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {

View File

@@ -171,7 +171,7 @@ func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenA
countStr += fmt.Sprintf("%v", tool.Function.Parameters)
}
}
toolTokens, err := CountTokenInput(countStr, request.Model)
toolTokens := CountTokenInput(countStr, request.Model)
if err != nil {
return 0, err
}
@@ -194,7 +194,7 @@ func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, erro
// Count tokens in system message
if request.System != "" {
systemTokens, err := CountTokenInput(request.System, model)
systemTokens := CountTokenInput(request.System, model)
if err != nil {
return 0, err
}
@@ -296,10 +296,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
switch request.Type {
case dto.RealtimeEventTypeSessionUpdate:
if request.Session != nil {
msgTokens, err := CountTextToken(request.Session.Instructions, model)
if err != nil {
return 0, 0, err
}
msgTokens := CountTextToken(request.Session.Instructions, model)
textToken += msgTokens
}
case dto.RealtimeEventResponseAudioDelta:
@@ -311,10 +308,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
audioToken += atk
case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta:
// count text token
tkm, err := CountTextToken(request.Delta, model)
if err != nil {
return 0, 0, fmt.Errorf("error counting text token: %v", err)
}
tkm := CountTextToken(request.Delta, model)
textToken += tkm
case dto.RealtimeEventInputAudioBufferAppend:
// count audio token
@@ -329,10 +323,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
case "message":
for _, content := range request.Item.Content {
if content.Type == "input_text" {
tokens, err := CountTextToken(content.Text, model)
if err != nil {
return 0, 0, err
}
tokens := CountTextToken(content.Text, model)
textToken += tokens
}
}
@@ -343,10 +334,7 @@ func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent,
if !info.IsFirstRequest {
if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 {
for _, tool := range info.RealtimeTools {
toolTokens, err := CountTokenInput(tool, model)
if err != nil {
return 0, 0, err
}
toolTokens := CountTokenInput(tool, model)
textToken += 8
textToken += toolTokens
}
@@ -409,7 +397,7 @@ func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, mod
return tokenNum, nil
}
func CountTokenInput(input any, model string) (int, error) {
func CountTokenInput(input any, model string) int {
switch v := input.(type) {
case string:
return CountTextToken(v, model)
@@ -432,13 +420,13 @@ func CountTokenInput(input any, model string) (int, error) {
func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice, model string) int {
tokens := 0
for _, message := range messages {
tkm, _ := CountTokenInput(message.Delta.GetContentString(), model)
tkm := CountTokenInput(message.Delta.GetContentString(), model)
tokens += tkm
if message.Delta.ToolCalls != nil {
for _, tool := range message.Delta.ToolCalls {
tkm, _ := CountTokenInput(tool.Function.Name, model)
tkm := CountTokenInput(tool.Function.Name, model)
tokens += tkm
tkm, _ = CountTokenInput(tool.Function.Arguments, model)
tkm = CountTokenInput(tool.Function.Arguments, model)
tokens += tkm
}
}
@@ -446,9 +434,9 @@ func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice,
return tokens
}
func CountTTSToken(text string, model string) (int, error) {
func CountTTSToken(text string, model string) int {
if strings.HasPrefix(model, "tts") {
return utf8.RuneCountInString(text), nil
return utf8.RuneCountInString(text)
} else {
return CountTextToken(text, model)
}
@@ -483,8 +471,10 @@ func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error)
//}
// CountTextToken 统计文本的token数量仅当文本包含敏感词返回错误同时返回token数量
func CountTextToken(text string, model string) (int, error) {
var err error
func CountTextToken(text string, model string) int {
if text == "" {
return 0
}
tokenEncoder := getTokenEncoder(model)
return getTokenNum(tokenEncoder, text), err
return getTokenNum(tokenEncoder, text)
}

View File

@@ -16,13 +16,13 @@ import (
// return 0, errors.New("unknown relay mode")
//}
func ResponseText2Usage(responseText string, modeName string, promptTokens int) (*dto.Usage, error) {
func ResponseText2Usage(responseText string, modeName string, promptTokens int) *dto.Usage {
usage := &dto.Usage{}
usage.PromptTokens = promptTokens
ctkm, err := CountTextToken(responseText, modeName)
ctkm := CountTextToken(responseText, modeName)
usage.CompletionTokens = ctkm
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return usage, err
return usage
}
func ValidUsage(usage *dto.Usage) bool {

View File

@@ -17,6 +17,8 @@ const (
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash`
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
)
@@ -64,10 +66,14 @@ func GetFileSearchPricePerThousand() float64 {
}
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
return Gemini25FlashNativeAudioInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
return Gemini25FlashLitePreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash") {
return Gemini25FlashProductionInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
}

View File

@@ -13,12 +13,12 @@ var PayMethods = []map[string]string{
{
"name": "支付宝",
"color": "rgba(var(--semi-blue-5), 1)",
"type": "zfb",
"type": "alipay",
},
{
"name": "微信",
"color": "rgba(var(--semi-green-5), 1)",
"type": "wx",
"type": "wxpay",
},
}

View File

@@ -85,7 +85,11 @@ func UpdateCacheRatioByJSONString(jsonStr string) error {
cacheRatioMapMutex.Lock()
defer cacheRatioMapMutex.Unlock()
cacheRatioMap = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &cacheRatioMap)
err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
// GetCacheRatio returns the cache ratio for a model
@@ -106,3 +110,13 @@ func GetCreateCacheRatio(name string) (float64, bool) {
}
return ratio, true
}
func GetCacheRatioCopy() map[string]float64 {
cacheRatioMapMutex.RLock()
defer cacheRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(cacheRatioMap))
for k, v := range cacheRatioMap {
copyMap[k] = v
}
return copyMap
}

View File

@@ -0,0 +1,17 @@
package ratio_setting
import "sync/atomic"
var exposeRatioEnabled atomic.Bool
func init() {
exposeRatioEnabled.Store(false)
}
func SetExposeRatioEnabled(enabled bool) {
exposeRatioEnabled.Store(enabled)
}
func IsExposeRatioEnabled() bool {
return exposeRatioEnabled.Load()
}

View File

@@ -0,0 +1,55 @@
package ratio_setting
import (
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
)
const exposedDataTTL = 30 * time.Second
type exposedCache struct {
data gin.H
expiresAt time.Time
}
var (
exposedData atomic.Value
rebuildMu sync.Mutex
)
func InvalidateExposedDataCache() {
exposedData.Store((*exposedCache)(nil))
}
func cloneGinH(src gin.H) gin.H {
dst := make(gin.H, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func GetExposedData() gin.H {
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
return cloneGinH(c.data)
}
rebuildMu.Lock()
defer rebuildMu.Unlock()
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
return cloneGinH(c.data)
}
newData := gin.H{
"model_ratio": GetModelRatioCopy(),
"completion_ratio": GetCompletionRatioCopy(),
"cache_ratio": GetCacheRatioCopy(),
"model_price": GetModelPriceCopy(),
}
exposedData.Store(&exposedCache{
data: newData,
expiresAt: time.Now().Add(exposedDataTTL),
})
return cloneGinH(newData)
}

View File

@@ -73,15 +73,15 @@ func GetGroupRatio(name string) float64 {
return ratio
}
func GetGroupGroupRatio(group, name string) (float64, bool) {
func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) {
groupGroupRatioMutex.RLock()
defer groupGroupRatioMutex.RUnlock()
gp, ok := GroupGroupRatio[group]
gp, ok := GroupGroupRatio[userGroup]
if !ok {
return -1, false
}
ratio, ok := gp[name]
ratio, ok := gp[usingGroup]
if !ok {
return -1, false
}

View File

@@ -140,6 +140,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.0-flash": 0.05,
"gemini-2.5-pro-exp-03-25": 0.625,
"gemini-2.5-pro-preview-03-25": 0.625,
"gemini-2.5-pro": 0.625,
"gemini-2.5-flash-preview-04-17": 0.075,
"gemini-2.5-flash-preview-04-17-thinking": 0.075,
"gemini-2.5-flash-preview-04-17-nothinking": 0.075,
@@ -148,6 +149,8 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-preview-05-20-nothinking": 0.075,
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -317,7 +320,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
modelPriceMapMutex.Lock()
defer modelPriceMapMutex.Unlock()
modelPriceMap = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &modelPriceMap)
err := json.Unmarshal([]byte(jsonStr), &modelPriceMap)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
// GetModelPrice 返回模型的价格,如果模型不存在则返回-1false
@@ -345,7 +352,11 @@ func UpdateModelRatioByJSONString(jsonStr string) error {
modelRatioMapMutex.Lock()
defer modelRatioMapMutex.Unlock()
modelRatioMap = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &modelRatioMap)
err := json.Unmarshal([]byte(jsonStr), &modelRatioMap)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
// 处理带有思考预算的模型名称,方便统一定价
@@ -405,13 +416,22 @@ func UpdateCompletionRatioByJSONString(jsonStr string) error {
CompletionRatioMutex.Lock()
defer CompletionRatioMutex.Unlock()
CompletionRatio = make(map[string]float64)
return json.Unmarshal([]byte(jsonStr), &CompletionRatio)
err := json.Unmarshal([]byte(jsonStr), &CompletionRatio)
if err == nil {
InvalidateExposedDataCache()
}
return err
}
func GetCompletionRatio(name string) float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4o-gizmo") {
name = "gpt-4o-gizmo-*"
}
if strings.Contains(name, "/") {
if ratio, ok := CompletionRatio[name]; ok {
return ratio
@@ -429,12 +449,6 @@ func GetCompletionRatio(name string) float64 {
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
lowercaseName := strings.ToLower(name)
if strings.HasPrefix(name, "gpt-4-gizmo") {
name = "gpt-4-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4o-gizmo") {
name = "gpt-4o-gizmo-*"
}
if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") {
if strings.HasPrefix(name, "gpt-4o") {
if name == "gpt-4o-2024-05-13" {
@@ -487,13 +501,18 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
} else if strings.HasPrefix(name, "gemini-2.0") {
return 4, true
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性这里假设正式版的倍率和preview一致
return 8, true
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 同上
if strings.HasSuffix(name, "-nothinking") {
return 4, false
} else {
return 3.5 / 0.6, false
return 8, false
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
if strings.HasSuffix(name, "-nothinking") {
return 4, false
}
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
return 4, true
}
return 2.5 / 0.3, true
}
return 4, false
}
@@ -609,3 +628,33 @@ func GetImageRatio(name string) (float64, bool) {
}
return ratio, true
}
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(modelRatioMap))
for k, v := range modelRatioMap {
copyMap[k] = v
}
return copyMap
}
func GetModelPriceCopy() map[string]float64 {
modelPriceMapMutex.RLock()
defer modelPriceMapMutex.RUnlock()
copyMap := make(map[string]float64, len(modelPriceMap))
for k, v := range modelPriceMap {
copyMap[k] = v
}
return copyMap
}
func GetCompletionRatioCopy() map[string]float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
copyMap := make(map[string]float64, len(CompletionRatio))
for k, v := range CompletionRatio {
copyMap[k] = v
}
return copyMap
}

View File

@@ -28,6 +28,7 @@ import {
Tag,
Typography,
Skeleton,
Badge,
} from '@douyinfe/semi-ui';
import { StatusContext } from '../../context/Status/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
@@ -43,6 +44,7 @@ const HeaderBar = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const systemName = getSystemName();
const logo = getLogo();
@@ -53,9 +55,44 @@ const HeaderBar = () => {
const docsLink = statusState?.status?.docs_link || '';
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const isConsoleRoute = location.pathname.startsWith('/console');
const theme = useTheme();
const setTheme = useSetTheme();
const announcements = statusState?.status?.announcements || [];
const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
const calculateUnreadCount = () => {
if (!announcements.length) return 0;
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const readSet = new Set(readKeys);
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
};
const getUnreadKeys = () => {
if (!announcements.length) return [];
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const readSet = new Set(readKeys);
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
};
useEffect(() => {
setUnreadCount(calculateUnreadCount());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [announcements]);
const mainNavLinks = [
{
text: t('首页'),
@@ -106,6 +143,25 @@ const HeaderBar = () => {
}, 3000);
};
const handleNoticeOpen = () => {
setNoticeVisible(true);
};
const handleNoticeClose = () => {
setNoticeVisible(false);
if (announcements.length) {
let readKeys = [];
try {
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
} catch (_) {
readKeys = [];
}
const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
}
setUnreadCount(0);
};
useEffect(() => {
if (theme === 'dark') {
document.body.setAttribute('theme-mode', 'dark');
@@ -353,15 +409,14 @@ const HeaderBar = () => {
}
};
// 检查当前路由是否以/console开头
const isConsoleRoute = location.pathname.startsWith('/console');
return (
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
onClose={handleNoticeClose}
isMobile={styleState.isMobile}
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
unreadKeys={getUnreadKeys()}
/>
<div className="w-full px-2">
<div className="flex items-center justify-between h-16">
@@ -462,14 +517,27 @@ const HeaderBar = () => {
</Dropdown>
)}
<Button
icon={<IconBell className="text-lg" />}
aria-label={t('系统公告')}
onClick={() => setNoticeVisible(true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
{unreadCount > 0 ? (
<Badge count={unreadCount} type="danger" overflowCount={99}>
<Button
icon={<IconBell className="text-lg" />}
aria-label={t('系统公告')}
onClick={handleNoticeOpen}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
</Badge>
) : (
<Button
icon={<IconBell className="text-lg" />}
aria-label={t('系统公告')}
onClick={handleNoticeOpen}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
)}
<Button
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}

View File

@@ -1,14 +1,36 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
import React, { useEffect, useState, useContext, useMemo } from 'react';
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../../helpers';
import { API, showError, getRelativeTime } from '../../helpers';
import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import { StatusContext } from '../../context/Status/index.js';
import { Bell, Megaphone } from 'lucide-react';
const NoticeModal = ({ visible, onClose, isMobile }) => {
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState(defaultTab);
const [statusState] = useContext(StatusContext);
const announcements = statusState?.status?.announcements || [];
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
const processedAnnouncements = useMemo(() => {
return (announcements || []).slice(0, 20).map(item => ({
key: getKeyForItem(item),
type: item.type || 'default',
time: getRelativeTime(item.publishDate),
content: item.content,
extra: item.extra,
isUnread: unreadSet.has(getKeyForItem(item))
}));
}, [announcements, unreadSet]);
const handleCloseTodayNotice = () => {
const today = new Date().toDateString();
@@ -44,7 +66,13 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
}
}, [visible]);
const renderContent = () => {
useEffect(() => {
if (visible) {
setActiveTab(defaultTab);
}
}, [defaultTab, visible]);
const renderMarkdownNotice = () => {
if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
}
@@ -64,14 +92,80 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
/>
);
};
const renderAnnouncementTimeline = () => {
if (processedAnnouncements.length === 0) {
return (
<div className="py-12">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('暂无系统公告')}
/>
</div>
);
}
return (
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
<Timeline mode="alternate">
{processedAnnouncements.map((item, idx) => {
const htmlContent = marked.parse(item.content || '');
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
return (
<Timeline.Item
key={idx}
type={item.type}
time={item.time}
className={item.isUnread ? '' : ''}
>
<div>
<div
className={item.isUnread ? 'shine-text' : ''}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
{item.extra && (
<div
className="text-xs text-gray-500"
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
)}
</div>
</Timeline.Item>
);
})}
</Timeline>
</div>
);
};
const renderBody = () => {
if (activeTab === 'inApp') {
return renderMarkdownNotice();
}
return renderAnnouncementTimeline();
};
return (
<Modal
title={t('系统公告')}
title={
<div className="flex items-center justify-between w-full">
<span>{t('系统公告')}</span>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
type='card'
size='small'
>
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
</Tabs>
</div>
}
visible={visible}
onCancel={onClose}
footer={(
@@ -82,7 +176,7 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
)}
size={isMobile ? 'full-width' : 'large'}
>
{renderContent()}
{renderBody()}
</Modal>
);
};

View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { isMobile } from '../../helpers';
import {
Modal,
Table,
Input,
Space,
Highlight,
Select,
Tag,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
const ChannelSelectorModal = forwardRef(({
visible,
onCancel,
onOk,
allChannels,
selectedChannelIds,
setSelectedChannelIds,
channelEndpoints,
updateChannelEndpoint,
t,
}, ref) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [filteredData, setFilteredData] = useState([]);
useImperativeHandle(ref, () => ({
resetPagination: () => {
setCurrentPage(1);
setSearchText('');
},
}));
useEffect(() => {
if (!allChannels) return;
const searchLower = searchText.trim().toLowerCase();
const matched = searchLower
? allChannels.filter((item) => {
const name = (item.label || '').toLowerCase();
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
return name.includes(searchLower) || baseUrl.includes(searchLower);
})
: allChannels;
setFilteredData(matched);
}, [allChannels, searchText]);
const total = filteredData.length;
const paginatedData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const updateEndpoint = (channelId, endpoint) => {
if (typeof updateChannelEndpoint === 'function') {
updateChannelEndpoint(channelId, endpoint);
}
};
const renderEndpointCell = (text, record) => {
const channelId = record.key || record.value;
const currentEndpoint = channelEndpoints[channelId] || '';
const getEndpointType = (ep) => {
if (ep === '/api/ratio_config') return 'ratio_config';
if (ep === '/api/pricing') return 'pricing';
return 'custom';
};
const currentType = getEndpointType(currentEndpoint);
const handleTypeChange = (val) => {
if (val === 'ratio_config') {
updateEndpoint(channelId, '/api/ratio_config');
} else if (val === 'pricing') {
updateEndpoint(channelId, '/api/pricing');
} else {
if (currentType !== 'custom') {
updateEndpoint(channelId, '');
}
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
size="small"
value={currentType}
onChange={handleTypeChange}
style={{ width: 120 }}
optionList={[
{ label: 'ratio_config', value: 'ratio_config' },
{ label: 'pricing', value: 'pricing' },
{ label: 'custom', value: 'custom' },
]}
/>
{currentType === 'custom' && (
<Input
size="small"
value={currentEndpoint}
onChange={(val) => updateEndpoint(channelId, val)}
placeholder="/your/endpoint"
style={{ width: 160, fontSize: 12 }}
/>
)}
</div>
);
};
const renderStatusCell = (status) => {
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
}
};
const renderNameCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const renderBaseUrlCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const columns = [
{
title: t('名称'),
dataIndex: 'label',
render: renderNameCell,
},
{
title: t('源地址'),
dataIndex: '_originalData.base_url',
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
},
{
title: t('状态'),
dataIndex: '_originalData.status',
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
},
{
title: t('同步接口'),
dataIndex: 'endpoint',
fixed: 'right',
render: renderEndpointCell,
},
];
const rowSelection = {
selectedRowKeys: selectedChannelIds,
onChange: (keys) => setSelectedChannelIds(keys),
};
return (
<Modal
visible={visible}
onCancel={onCancel}
onOk={onOk}
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
size={isMobile() ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
<Space vertical style={{ width: '100%' }}>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索渠道名称或地址')}
value={searchText}
onChange={setSearchText}
showClear
className="!rounded-full"
/>
<Table
columns={columns}
dataSource={paginatedData}
rowKey="key"
rowSelection={rowSelection}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: total,
}),
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (curr, size) => {
setCurrentPage(1);
setPageSize(size);
},
}}
size="small"
/>
</Space>
</Modal>
);
});
export default ChannelSelectorModal;

View File

@@ -0,0 +1,63 @@
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';
const ChatsSetting = () => {
let [inputs, setInputs] = useState({
/* 聊天设置 */
Chats: '[]',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 聊天设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsChats options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default ChatsSetting;

View File

@@ -5,6 +5,7 @@ import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard.js';
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
@@ -23,6 +24,11 @@ const DashboardSetting = () => {
FAQ: '',
UptimeKumaUrl: '',
UptimeKumaSlug: '',
/* 数据看板 */
DataExportEnabled: false,
DataExportDefaultTime: 'hour',
DataExportInterval: 5,
});
let [loading, setLoading] = useState(false);
@@ -37,6 +43,10 @@ const DashboardSetting = () => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
if (item.key.endsWith('Enabled') &&
(item.key === 'DataExportEnabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
}
});
setInputs(newInputs);
} else {
@@ -106,9 +116,9 @@ const DashboardSetting = () => {
</p>
</Modal>
{/* API信息管理 */}
{/* 数据看板设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
</Card>
{/* 系统公告管理 */}
@@ -116,6 +126,11 @@ const DashboardSetting = () => {
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
</Card>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
</Card>
{/* 常见问答管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsFAQ options={inputs} refresh={onRefresh} />

View File

@@ -0,0 +1,65 @@
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';
const DrawingSetting = () => {
let [inputs, setInputs] = useState({
/* 绘图设置 */
DrawingEnabled: false,
MjNotifyEnabled: false,
MjAccountFilterEnabled: false,
MjForwardUrlEnabled: false,
MjModeClearEnabled: false,
MjActionCheckSuccessEnabled: false,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 绘图设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsDrawing options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DrawingSetting;

View File

@@ -1,13 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { API, showError } from '../../helpers';
const OperationSetting = () => {
@@ -29,14 +26,6 @@ const OperationSetting = () => {
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
/* 绘图设置 */
DrawingEnabled: false,
MjNotifyEnabled: false,
MjAccountFilterEnabled: false,
MjForwardUrlEnabled: false,
MjModeClearEnabled: false,
MjActionCheckSuccessEnabled: false,
/* 敏感词设置 */
CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false,
@@ -45,20 +34,12 @@ const OperationSetting = () => {
/* 日志设置 */
LogConsumeEnabled: false,
/* 数据看板 */
DataExportEnabled: false,
DataExportDefaultTime: 'hour',
DataExportInterval: 5,
/* 监控设置 */
ChannelDisableThreshold: 0,
QuotaRemindThreshold: 0,
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
/* 聊天设置 */
Chats: '[]',
});
let [loading, setLoading] = useState(false);
@@ -107,10 +88,6 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsGeneral options={inputs} refresh={onRefresh} />
</Card>
{/* 绘图设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsDrawing options={inputs} refresh={onRefresh} />
</Card>
{/* 屏蔽词过滤设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
@@ -119,10 +96,6 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsLog options={inputs} refresh={onRefresh} />
</Card>
{/* 数据看板 */}
<Card style={{ marginTop: '10px' }}>
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
</Card>
{/* 监控设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsMonitoring options={inputs} refresh={onRefresh} />
@@ -131,10 +104,6 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsCreditLimit options={inputs} refresh={onRefresh} />
</Card>
{/* 聊天设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsChats options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);

View File

@@ -0,0 +1,88 @@
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 { useTranslation } from 'react-i18next';
const PaymentSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
ServerAddress: '',
PayAddress: '',
EpayId: '',
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
switch (item.key) {
case 'TopupGroupRatio':
try {
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (error) {
console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value;
}
break;
case 'Price':
case 'MinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
}
break;
}
});
setInputs(newInputs);
} else {
showError(t(message));
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError(t('刷新失败'));
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default PaymentSetting;

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers/index.js';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { API, showError } from '../../helpers/index.js';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
@@ -24,14 +23,14 @@ const RateLimitSetting = () => {
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key === 'ModelRequestRateLimitGroup') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (item.key === 'ModelRequestRateLimitGroup') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
}
});

View File

@@ -6,6 +6,7 @@ import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js'
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js';
import { API, showError } from '../../helpers';
@@ -21,6 +22,7 @@ const RatioSetting = () => {
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
UserUsableGroups: '',
});
@@ -48,7 +50,7 @@ const RatioSetting = () => {
// 如果后端返回的不是合法 JSON直接展示
}
}
if (['DefaultUseAutoGroup'].includes(item.key)) {
if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) {
newInputs[item.key] = item.value === 'true' ? true : false;
} else {
newInputs[item.key] = item.value;
@@ -78,15 +80,20 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 分组倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Card>
{/* 模型倍率设置以及可视化编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='line'>
<Tabs type='card'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
<ModelRatioSettings
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
<GroupRatioSettings
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<ModelSettingsVisualEditor
@@ -100,6 +107,12 @@ const RatioSetting = () => {
refresh={onRefresh}
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
<UpstreamRatioSync
options={inputs}
refresh={onRefresh}
/>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>

View File

@@ -17,7 +17,6 @@ import {
removeTrailingSlash,
showError,
showSuccess,
verifyJSON,
} from '../../helpers';
import axios from 'axios';
@@ -42,17 +41,9 @@ const SystemSetting = () => {
SMTPAccount: '',
SMTPFrom: '',
SMTPToken: '',
ServerAddress: '',
WorkerUrl: '',
WorkerValidKey: '',
WorkerAllowHttpImageRequestEnabled: '',
EpayId: '',
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
TopupGroupRatio: '',
PayAddress: '',
CustomCallbackAddress: '',
Footer: '',
WeChatAuthEnabled: '',
WeChatServerAddress: '',
@@ -73,7 +64,6 @@ const SystemSetting = () => {
LinuxDOOAuthEnabled: '',
LinuxDOClientId: '',
LinuxDOClientSecret: '',
PayMethods: '',
});
const [originInputs, setOriginInputs] = useState({});
@@ -200,11 +190,6 @@ const SystemSetting = () => {
setInputs(values);
};
const submitServerAddress = async () => {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]);
};
const submitWorker = async () => {
let WorkerUrl = removeTrailingSlash(inputs.WorkerUrl);
const options = [
@@ -220,56 +205,6 @@ const SystemSetting = () => {
await updateOptions(options);
};
const submitPayAddress = async () => {
if (inputs.ServerAddress === '') {
showError('请先填写服务器地址');
return;
}
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
if (!verifyJSON(inputs.TopupGroupRatio)) {
showError('充值分组倍率不是合法的 JSON 字符串');
return;
}
}
if (originInputs['PayMethods'] !== inputs.PayMethods) {
if (!verifyJSON(inputs.PayMethods)) {
showError('充值方式设置不是合法的 JSON 字符串');
return;
}
}
const options = [
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
];
if (inputs.EpayId !== '') {
options.push({ key: 'EpayId', value: inputs.EpayId });
}
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
options.push({ key: 'EpayKey', value: inputs.EpayKey });
}
if (inputs.Price !== '') {
options.push({ key: 'Price', value: inputs.Price.toString() });
}
if (inputs.MinTopUp !== '') {
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
}
if (inputs.CustomCallbackAddress !== '') {
options.push({
key: 'CustomCallbackAddress',
value: inputs.CustomCallbackAddress,
});
}
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
}
if (originInputs['PayMethods'] !== inputs.PayMethods) {
options.push({ key: 'PayMethods', value: inputs.PayMethods });
}
await updateOptions(options);
};
const submitSMTP = async () => {
const options = [];
@@ -551,17 +486,6 @@ const SystemSetting = () => {
marginTop: '10px',
}}
>
<Card>
<Form.Section text='通用设置'>
<Form.Input
field='ServerAddress'
label='服务器地址'
placeholder='例如https://yourdomain.com'
style={{ width: '100%' }}
/>
<Button onClick={submitServerAddress}>更新服务器地址</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='代理设置'>
<Text>
@@ -604,80 +528,6 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<Card>
<Form.Section text='支付设置'>
<Text>
当前仅支持易支付接口默认使用上方服务器地址作为回调地址
</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='PayAddress'
label='支付地址'
placeholder='例如https://yourdomain.com'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='EpayId'
label='易支付商户ID'
placeholder='例如0001'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='EpayKey'
label='易支付商户密钥'
placeholder='敏感信息不会发送到前端显示'
type='password'
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CustomCallbackAddress'
label='回调地址'
placeholder='例如https://yourdomain.com'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='Price'
precision={2}
label='充值价格x元/美金)'
placeholder='例如7就是7元/美金'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='MinTopUp'
label='最低充值美元数量'
placeholder='例如2就是最低充值2$'
/>
</Col>
</Row>
<Form.TextArea
field='TopupGroupRatio'
label='充值分组倍率'
placeholder='为一个 JSON 文本,键为组名称,值为倍率'
autosize
/>
<Form.TextArea
field='PayMethods'
label='充值方式设置'
placeholder='为一个 JSON 文本'
autosize
/>
<Button onClick={submitPayAddress}>更新支付设置</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置登录注册'>
<Row

View File

@@ -17,10 +17,10 @@ import {
AlertCircle,
HelpCircle,
Coins,
Tags
Tags,
} from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants/index.js';
import {
Button,
Divider,
@@ -40,7 +40,8 @@ import {
Card,
Form,
Tabs,
TabPane
TabPane,
Select,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -52,6 +53,7 @@ import {
IconPlus,
IconRefresh,
IconSetting,
IconDescend,
IconSearch,
IconEdit,
IconDelete,
@@ -61,9 +63,10 @@ import {
IconCopy,
IconSmallTriangleRight
} from '@douyinfe/semi-icons';
import { loadChannelModels } from '../../helpers/index.js';
import { loadChannelModels, isMobile, copy } from '../../helpers';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const ChannelsTable = () => {
const { t } = useTranslation();
@@ -114,7 +117,7 @@ const ChannelsTable = () => {
);
case 2:
return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
@@ -187,6 +190,11 @@ const ChannelsTable = () => {
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// 状态筛选 all / enabled / disabled
const [statusFilter, setStatusFilter] = useState(
localStorage.getItem('channel-status-filter') || 'all'
);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem('channels-table-columns');
@@ -676,18 +684,22 @@ const ChannelsTable = () => {
const [modelSearchKeyword, setModelSearchKeyword] = useState('');
const [modelTestResults, setModelTestResults] = useState({});
const [testingModels, setTestingModels] = useState(new Set());
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
const [isBatchTesting, setIsBatchTesting] = useState(false);
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
const [modelTablePage, setModelTablePage] = useState(1);
const [activeTypeKey, setActiveTypeKey] = useState('all');
const [typeCounts, setTypeCounts] = useState({});
const requestCounter = useRef(0);
const [formApi, setFormApi] = useState(null);
const [compactMode, setCompactMode] = useTableCompactMode('channels');
const formInitValues = {
searchKeyword: '',
searchGroup: '',
searchModel: '',
};
const allSelectingRef = useRef(false);
// Filter columns based on visibility settings
const getVisibleColumns = () => {
@@ -864,12 +876,30 @@ const ChannelsTable = () => {
setChannels(channelDates);
};
const loadChannels = async (page, pageSize, idSort, enableTagMode, typeKey = activeTypeKey) => {
const loadChannels = async (
page,
pageSize,
idSort,
enableTagMode,
typeKey = activeTypeKey,
statusF,
) => {
if (statusF === undefined) statusF = statusFilter;
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
setLoading(true);
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
setLoading(false);
return;
}
const reqId = ++requestCounter.current; // 记录当前请求序号
setLoading(true);
const typeParam = (!enableTagMode && typeKey !== 'all') ? `&type=${typeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
);
if (res === undefined || reqId !== requestCounter.current) {
return;
@@ -920,7 +950,7 @@ const ChannelsTable = () => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode);
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
}
};
@@ -1026,7 +1056,7 @@ const ChannelsTable = () => {
}
};
// 获取表单值的辅助函数,确保所有值都是字符串
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
@@ -1036,27 +1066,35 @@ const ChannelsTable = () => {
};
};
const searchChannels = async (enableTagMode) => {
const searchChannels = async (
enableTagMode,
typeKey = activeTypeKey,
statusF = statusFilter,
page = 1,
pageSz = pageSize,
sortFlag = idSort,
) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
return;
}
const typeParam = (!enableTagMode && activeTypeKey !== 'all') ? `&type=${activeTypeKey}` : '';
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}`,
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
);
const { success, message, data } = res.data;
if (success) {
const { items = [], type_counts = {} } = data;
const { items = [], total = 0, type_counts = {} } = data;
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
setTypeCounts({ ...type_counts, all: sumAll });
setChannelFormat(items, enableTagMode);
setActivePage(1);
setChannelCount(total);
setActivePage(page);
} else {
showError(message);
}
@@ -1096,7 +1134,22 @@ const ChannelsTable = () => {
const processTestQueue = async () => {
if (!isProcessingQueue || testQueue.length === 0) return;
const { channel, model } = testQueue[0];
const { channel, model, indexInFiltered } = testQueue[0];
// 自动翻页到正在测试的模型所在页
if (currentTestChannel && currentTestChannel.id === channel.id) {
let pageNo;
if (indexInFiltered !== undefined) {
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
} else {
const filteredModelsList = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
const modelIdx = filteredModelsList.indexOf(model);
pageNo = modelIdx !== -1 ? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1 : 1;
}
setModelTablePage(pageNo);
}
try {
setTestingModels(prev => new Set([...prev, model]));
@@ -1159,16 +1212,22 @@ const ChannelsTable = () => {
setIsBatchTesting(true);
const models = currentTestChannel.models
// 重置分页到第一页
setModelTablePage(1);
const filteredModels = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
setTestQueue(models.map(model => ({
channel: currentTestChannel,
model
})));
setTestQueue(
filteredModels.map((model, idx) => ({
channel: currentTestChannel,
model,
indexInFiltered: idx, // 记录在过滤列表中的顺序
})),
);
setIsProcessingQueue(true);
};
@@ -1182,6 +1241,8 @@ const ChannelsTable = () => {
} else {
setShowModelTestModal(false);
setModelSearchKeyword('');
setSelectedModelKeys([]);
setModelTablePage(1);
}
};
@@ -1262,32 +1323,31 @@ const ChannelsTable = () => {
};
let pageData = channels;
if (activeTypeKey !== 'all') {
const typeVal = parseInt(activeTypeKey);
if (!isNaN(typeVal)) {
pageData = pageData.filter((ch) => {
if (ch.children !== undefined) {
return ch.children.some((c) => c.type === typeVal);
}
return ch.type === typeVal;
});
}
}
const handlePageChange = (page) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setActivePage(page);
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(1, size, idSort, enableTagMode)
.then()
.catch((reason) => {
showError(reason);
});
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
}
};
const fetchGroups = async () => {
@@ -1468,6 +1528,7 @@ const ChannelsTable = () => {
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex flex-wrap md:flex-nowrap items-center gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='danger'
@@ -1484,6 +1545,7 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='primary'
@@ -1494,11 +1556,13 @@ const ChannelsTable = () => {
</Button>
<Dropdown
size='small'
trigger='click'
render={
<Dropdown.Menu>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='warning'
className="!rounded-full w-full"
@@ -1517,6 +1581,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='secondary'
className="!rounded-full w-full"
@@ -1535,6 +1600,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='danger'
className="!rounded-full w-full"
@@ -1553,6 +1619,7 @@ const ChannelsTable = () => {
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='tertiary'
className="!rounded-full w-full"
@@ -1572,10 +1639,20 @@ const ChannelsTable = () => {
</Dropdown.Menu>
}
>
<Button theme='light' type='tertiary' icon={<IconSetting />} className="!rounded-full w-full md:w-auto">
<Button size='small' theme='light' type='tertiary' className="!rounded-full w-full md:w-auto">
{t('批量操作')}
</Button>
</Dropdown>
<Button
size='small'
theme='light'
type='secondary'
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto order-1 md:order-2">
@@ -1584,11 +1661,17 @@ const ChannelsTable = () => {
{t('使用ID排序')}
</Typography.Text>
<Switch
size='small'
checked={idSort}
onChange={(v) => {
localStorage.setItem('id-sort', v + '');
setIdSort(v);
loadChannels(activePage, pageSize, v, enableTagMode);
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
loadChannels(activePage, pageSize, v, enableTagMode);
} else {
searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, v);
}
}}
/>
</div>
@@ -1598,6 +1681,7 @@ const ChannelsTable = () => {
{t('开启批量操作')}
</Typography.Text>
<Switch
size='small'
checked={enableBatchDelete}
onChange={(v) => {
localStorage.setItem('enable-batch-delete', v + '');
@@ -1611,6 +1695,7 @@ const ChannelsTable = () => {
{t('标签聚合模式')}
</Typography.Text>
<Switch
size='small'
checked={enableTagMode}
onChange={(v) => {
localStorage.setItem('enable-tag-mode', v + '');
@@ -1620,6 +1705,27 @@ const ChannelsTable = () => {
}}
/>
</div>
{/* 状态筛选器 */}
<div className="flex items-center justify-between w-full md:w-auto">
<Typography.Text strong className="mr-2">
{t('状态筛选')}
</Typography.Text>
<Select
size='small'
value={statusFilter}
onChange={(v) => {
localStorage.setItem('channel-status-filter', v);
setStatusFilter(v);
setActivePage(1);
loadChannels(1, pageSize, idSort, enableTagMode, activeTypeKey, v);
}}
>
<Select.Option value="all">{t('全部')}</Select.Option>
<Select.Option value="enabled">{t('已启用')}</Select.Option>
<Select.Option value="disabled">{t('已禁用')}</Select.Option>
</Select>
</div>
</div>
</div>
@@ -1628,6 +1734,7 @@ const ChannelsTable = () => {
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
size='small'
theme='light'
type='primary'
icon={<IconPlus />}
@@ -1643,9 +1750,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='primary'
icon={<IconRefresh />}
className="!rounded-full w-full md:w-auto"
onClick={refresh}
>
@@ -1653,9 +1760,9 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
>
@@ -1677,9 +1784,10 @@ const ChannelsTable = () => {
>
<div className="relative w-full md:w-64">
<Form.Input
size='small'
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID名称密钥API地址 ...')}
placeholder={t('渠道ID名称密钥API地址')}
className="!rounded-full"
showClear
pure
@@ -1687,6 +1795,7 @@ const ChannelsTable = () => {
</div>
<div className="w-full md:w-48">
<Form.Input
size='small'
field="searchModel"
prefix={<IconSearch />}
placeholder={t('模型关键字')}
@@ -1695,8 +1804,9 @@ const ChannelsTable = () => {
pure
/>
</div>
<div className="w-full md:w-48">
<div className="w-full md:w-32">
<Form.Select
size='small'
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
@@ -1715,6 +1825,7 @@ const ChannelsTable = () => {
/>
</div>
<Button
size='small'
type="primary"
htmlType="submit"
loading={loading || searching}
@@ -1723,6 +1834,7 @@ const ChannelsTable = () => {
{t('查询')}
</Button>
<Button
size='small'
theme='light'
onClick={() => {
if (formApi) {
@@ -1766,9 +1878,9 @@ const ChannelsTable = () => {
bordered={false}
>
<Table
columns={getVisibleColumns()}
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
dataSource={pageData}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
@@ -1806,7 +1918,7 @@ const ChannelsTable = () => {
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}
loading={loading || searching}
/>
</Card>
@@ -1842,13 +1954,73 @@ const ChannelsTable = () => {
<Modal
title={
currentTestChannel && (
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center">
{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 !rounded-full"
prefix={<IconSearch />}
showClear
/>
<Button
theme='light'
icon={<IconCopy />}
className="!rounded-full"
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'
className="!rounded-full"
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>
)
}
@@ -1898,22 +2070,11 @@ const ChannelsTable = () => {
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size="large"
size={isMobile() ? 'full-width' : 'large'}
>
<div className="max-h-[600px] overflow-y-auto">
<div className="model-test-scroll">
{currentTestChannel && (
<div>
<div className="flex items-center justify-end mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => setModelSearchKeyword(v)}
className="w-64 !rounded-full"
prefix={<IconSearch />}
showClear
/>
</div>
<Table
columns={[
{
@@ -1987,16 +2148,47 @@ const ChannelsTable = () => {
}
}
]}
dataSource={currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
)
.map((model) => ({
dataSource={(() => {
const filtered = currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
);
const start = (modelTablePage - 1) * MODEL_TABLE_PAGE_SIZE;
const end = start + MODEL_TABLE_PAGE_SIZE;
return filtered.slice(start, end).map((model) => ({
model,
key: model
}))}
pagination={false}
key: model,
}));
})()}
rowSelection={{
selectedRowKeys: selectedModelKeys,
onChange: (keys) => {
if (allSelectingRef.current) {
allSelectingRef.current = false;
return;
}
setSelectedModelKeys(keys);
},
onSelectAll: (checked) => {
const filtered = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()));
allSelectingRef.current = true;
setSelectedModelKeys(checked ? filtered : []);
},
}}
pagination={{
currentPage: modelTablePage,
pageSize: MODEL_TABLE_PAGE_SIZE,
total: currentTestChannel.models
.split(',')
.filter((model) =>
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
).length,
showSizeChanger: false,
onPageChange: (page) => setModelTablePage(page),
}}
/>
</div>
)}

View File

@@ -47,8 +47,9 @@ import {
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
import { IconSetting, IconSearch, IconHelpCircle, IconDescend } from '@douyinfe/semi-icons';
import { Route } from 'lucide-react';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
@@ -192,7 +193,7 @@ const LogsTable = () => {
if (!modelMapped) {
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
});
} else {
@@ -209,7 +210,7 @@ const LogsTable = () => {
</Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
})}
</div>
@@ -220,7 +221,7 @@ const LogsTable = () => {
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => {},
(r) => { },
);
},
})}
@@ -231,7 +232,7 @@ const LogsTable = () => {
>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => {});
copyText(event, record.model_name).then((r) => { });
},
suffixIcon: (
<Route
@@ -636,23 +637,23 @@ const LogsTable = () => {
}
let content = other?.claude
? renderClaudeModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
other.model_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
return (
<Paragraph
ellipsis={{
@@ -985,27 +986,27 @@ const LogsTable = () => {
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
});
}
if (logs[i].type === 2) {
@@ -1145,7 +1146,7 @@ const LogsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize).then((r) => {}); // 不传入logType让其从表单获取最新值
loadLogs(page, pageSize).then((r) => { }); // 不传入logType让其从表单获取最新值
};
const handlePageSizeChange = async (size) => {
@@ -1203,6 +1204,8 @@ const LogsTable = () => {
);
};
const [compactMode, setCompactMode] = useTableCompactMode('logs');
return (
<>
{renderColumnSelector()}
@@ -1211,45 +1214,57 @@ const LogsTable = () => {
title={
<div className='flex flex-col w-full'>
<Spin spinning={loadingStat}>
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
{t('消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
RPM: {stat.rpm}
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '9999px',
fontWeight: 500,
}}
>
TPM: {stat.tpm}
</Tag>
</Space>
<Button
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{t('消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
RPM: {stat.rpm}
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '9999px',
fontWeight: 500,
}}
>
TPM: {stat.tpm}
</Tag>
</Space>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
</Spin>
<Divider margin='12px' />
@@ -1382,7 +1397,6 @@ const LogsTable = () => {
if (formApi) {
formApi.reset();
setLogType(0);
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
@@ -1411,7 +1425,7 @@ const LogsTable = () => {
bordered={false}
>
<Table
columns={getVisibleColumns()}
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
{...(hasExpandableRows() && {
expandedRowRender: expandRowRender,
expandRowByClick: true,
@@ -1421,7 +1435,7 @@ const LogsTable = () => {
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
empty={

View File

@@ -24,7 +24,7 @@ import {
XCircle,
Loader,
AlertCircle,
Hash
Hash,
} from 'lucide-react';
import {
API,
@@ -59,8 +59,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting
IconSetting,
IconDescend
} from '@douyinfe/semi-icons';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
@@ -107,6 +109,7 @@ const LogsTable = () => {
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
const isAdminUser = isAdmin();
const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
// 加载保存的列偏好设置
useEffect(() => {
@@ -802,7 +805,7 @@ const LogsTable = () => {
className="!rounded-2xl mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<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 ? (
@@ -821,6 +824,15 @@ const LogsTable = () => {
</Text>
)}
</div>
<Button
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
<Divider margin="12px" />
@@ -919,11 +931,11 @@ const LogsTable = () => {
bordered={false}
>
<Table
columns={getVisibleColumns()}
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={

View File

@@ -45,10 +45,12 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore
IconMore,
IconDescend
} from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
@@ -266,6 +268,7 @@ const RedemptionsTable = () => {
id: undefined,
});
const [showEdit, setShowEdit] = useState(false);
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
// Form 初始值
const formInitValues = {
@@ -465,9 +468,20 @@ const RedemptionsTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-orange-500">
<Ticket size={16} className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
<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">
<Ticket size={16} className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
<Button
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
</div>
@@ -610,9 +624,9 @@ const RedemptionsTable = () => {
bordered={false}
>
<Table
columns={columns}
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,

View File

@@ -11,7 +11,9 @@ import {
XCircle,
Loader,
List,
Hash
Hash,
Video,
Sparkles
} from 'lucide-react';
import {
API,
@@ -45,8 +47,10 @@ import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting
IconSetting,
IconDescend
} from '@douyinfe/semi-icons';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
@@ -80,6 +84,7 @@ const COLUMN_KEYS = {
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
FAIL_REASON: 'fail_reason',
RESULT_URL: 'result_url',
};
const renderTimestamp = (timestampInSeconds) => {
@@ -150,6 +155,7 @@ const LogsTable = () => {
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
[COLUMN_KEYS.RESULT_URL]: true,
};
};
@@ -203,6 +209,12 @@ const LogsTable = () => {
{t('生成歌词')}
</Tag>
);
case 'generate':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('生成视频')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
@@ -220,6 +232,12 @@ const LogsTable = () => {
Suno
</Tag>
);
case 'kling':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Video size={14} />}>
Kling
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
@@ -411,10 +429,21 @@ const LogsTable = () => {
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
title: t('详情'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
const isVideoTask = record.action === 'generate';
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {
return (
<a href={text} target="_blank" rel="noopener noreferrer">
{t('点击预览视频')}
</a>
);
}
if (!text) {
return t('无');
}
@@ -444,6 +473,8 @@ const LogsTable = () => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
@@ -623,7 +654,7 @@ const LogsTable = () => {
className="!rounded-2xl mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<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 ? (
@@ -638,6 +669,15 @@ const LogsTable = () => {
<Text>{t('任务记录')}</Text>
)}
</div>
<Button
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
<Divider margin="12px" />
@@ -736,11 +776,11 @@ const LogsTable = () => {
bordered={false}
>
<Table
columns={getVisibleColumns()}
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import {
API,
copy,
@@ -52,10 +52,12 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore
IconMore,
IconDescend
} from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
@@ -385,6 +387,7 @@ const TokensTable = () => {
const [editingToken, setEditingToken] = useState({
id: undefined,
});
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
// Form 初始值
const formInitValues = {
@@ -435,6 +438,7 @@ const TokensTable = () => {
const refresh = async () => {
await loadTokens(1);
setSelectedKeys([]);
};
const copyText = async (text) => {
@@ -583,24 +587,58 @@ const TokensTable = () => {
}
};
const batchDeleteTokens = async () => {
if (selectedKeys.length === 0) {
showError(t('请先选择要删除的令牌!'));
return;
}
setLoading(true);
try {
const ids = selectedKeys.map((token) => token.id);
const res = await API.post('/api/token/batch', { ids });
if (res?.data?.success) {
const count = res.data.data || 0;
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
await refresh();
} else {
showError(res?.data?.message || t('删除失败'));
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Key size={16} className="mr-2" />
<Text>{t('令牌用于API访问认证可以设置额度限制和模型权限。')}</Text>
<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-blue-500">
<Key size={16} className="mr-2" />
<Text>{t('令牌用于API访问认证可以设置额度限制和模型权限。')}</Text>
</div>
<Button
theme="light"
type="secondary"
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme="light"
type="primary"
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
className="!rounded-full flex-1 md:flex-initial"
onClick={() => {
setEditingToken({
id: undefined,
@@ -614,21 +652,76 @@ const TokensTable = () => {
theme="light"
type="warning"
icon={<IconCopy />}
className="!rounded-full w-full md:w-auto"
onClick={async () => {
className="!rounded-full flex-1 md:flex-initial"
onClick={() => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(keys);
Modal.info({
title: t('复制令牌'),
icon: null,
content: t('请选择你的复制方式'),
footer: (
<Space>
<Button
type="primary"
theme="solid"
icon={<IconCopy />}
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('名称+密钥')}
</Button>
<Button
theme="light"
icon={<IconCopy />}
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
content += 'sk-' + selectedKeys[i].key + '\n';
}
await copyText(content);
Modal.destroyAll();
}}
>
{t('仅密钥')}
</Button>
</Space>
),
});
}}
>
{t('复制所选令牌到剪贴板')}
{t('复制所选令牌')}
</Button>
<Button
theme="light"
type="danger"
className="!rounded-full w-full md:w-auto"
onClick={() => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
Modal.confirm({
title: t('批量删除令牌'),
content: (
<div>
{t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
</div>
),
onOk: () => batchDeleteTokens(),
});
}}
>
{t('删除所选令牌')}
</Button>
</div>
@@ -711,9 +804,15 @@ const TokensTable = () => {
bordered={false}
>
<Table
columns={columns}
columns={compactMode ? columns.map(col => {
if (col.dataIndex === 'operate') {
const { fixed, ...rest } = col;
return rest;
}
return col;
}) : columns}
dataSource={tokens}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,

View File

@@ -13,7 +13,7 @@ import {
Activity,
Users,
DollarSign,
UserPlus
UserPlus,
} from 'lucide-react';
import {
Button,
@@ -43,17 +43,20 @@ import {
IconMore,
IconUserAdd,
IconArrowUp,
IconArrowDown
IconArrowDown,
IconDescend
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
const { Text } = Typography;
const UsersTable = () => {
const { t } = useTranslation();
const [compactMode, setCompactMode] = useTableCompactMode('users');
function renderRole(role) {
switch (role) {
@@ -527,9 +530,20 @@ const UsersTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
<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-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</div>
<Button
theme='light'
type='secondary'
icon={<IconDescend />}
className="!rounded-full w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
</div>
</div>
@@ -645,9 +659,9 @@ const UsersTable = () => {
bordered={false}
>
<Table
columns={columns}
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
dataSource={users}
scroll={{ x: 'max-content' }}
scroll={compactMode ? undefined : { x: 'max-content' }}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -125,4 +125,11 @@ export const CHANNEL_OPTIONS = [
color: 'blue',
label: 'Coze',
},
{
value: 50,
color: 'green',
label: '可灵',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;

View File

@@ -1 +1,5 @@
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
export const DEFAULT_ENDPOINT = '/api/ratio_config';
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';

View File

@@ -3,6 +3,7 @@ import { toastConstants } from '../constants';
import React from 'react';
import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
import { TABLE_COMPACT_MODES_KEY } from '../constants';
const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => {
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
function readTableCompactModes() {
try {
const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
return json ? JSON.parse(json) : {};
} catch {
return {};
}
}
function writeTableCompactModes(modes) {
try {
localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
} catch {
// ignore
}
}
export function getTableCompactMode(tableKey = 'global') {
const modes = readTableCompactModes();
return !!modes[tableKey];
}
export function setTableCompactMode(compact, tableKey = 'global') {
const modes = readTableCompactModes();
modes[tableKey] = compact;
writeTableCompactModes(modes);
}

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