Compare commits

...

59 Commits

Author SHA1 Message Date
CaIon 51086c3ba6 🔧 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 7a4c213b65 🎨 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 610853e9c8 🚀 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 62daf16b19 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 d19ab54e32 🚀 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 8f0f0c0d27 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 9bf32ef581 Revert "🐛 fix: preserve group filter when switching channel type/status"
This reverts commit 4949d986c7.
2025-06-24 01:51:26 +08:00
t0ng7u 9469c4973c 💄 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 4949d986c7 🐛 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 949d462534 🐛 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 fc2e2c1aff 🎨 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
同語 e7506ee9cf 🧬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 13fd901d17 🚀 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 bce87295b6 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-23 17:35:50 +08:00
t0ng7u 0ac7406db8 🚀 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 ef9c5b3acb 🐛 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 c9529d00d5 Merge branch 'main' into feat-copy-models 2025-06-23 16:12:18 +08:00
t0ng7u f73da57acb 🎛️ 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 8a1e437ce9 🔧 chore: update STREAMING_TIMEOUT default value to 120 seconds in configuration 2025-06-22 18:47:40 +08:00
t0ng7u 6cac7d96e7 🍭style: update home main title style font-semibold to font-bold 2025-06-22 18:28:32 +08:00
t0ng7u 2df9ed2892 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 18:10:44 +08:00
t0ng7u 6bd4e202a1 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 9d3b84158c 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 32366d1e1b refactor: streamline price calculation in RelaySwapFace and RelayMidjourneySubmit functions 2025-06-22 17:52:48 +08:00
CaIon 20e233ea8a Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 16:59:22 +08:00
CaIon 9121a593d7 fix: reset channel key in UpdateChannel function 2025-06-22 16:59:06 +08:00
t0ng7u d11798cc68 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 2d305d5bb1 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 16:39:52 +08:00
t0ng7u 1ad9c97d7c 🗑️ 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 e5fe592953 task userGroupRatio 2025-06-22 15:52:25 +08:00
creamlike1024 2c20827a27 fix: mj userGroupRatio 2025-06-22 15:47:30 +08:00
CaIon d084083e05 fix: update JSON decoding and budget token handling in RequestOpenAI2ClaudeMessage 2025-06-22 01:15:01 +08:00
Calcium-Ion 877529ef6b 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 2dee2300eb Merge pull request #1235 from prnake/thinking-fix-0616
feat: openrouter format for claude request
2025-06-22 01:08:01 +08:00
CaIon 131b62d065 Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-22 00:59:56 +08:00
CaIon 93fb5bab6b fix(dto): change Created field type in OpenAITextResponse to any. (close #1131) 2025-06-22 00:59:39 +08:00
Calcium-Ion 45543411cb 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 e69f4199ea Merge remote-tracking branch 'origin/alpha' into alpha 2025-06-21 22:31:54 +08:00
t0ng7u 8da8c2f503 📝 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 49458a3a64 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 ff85dfd851 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 7de52a0c0d 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 1add4bf937 feat: kling apiKey format to use | delimiter 2025-06-21 21:38:36 +08:00
skynono 3019336161 feat: add placeholder for kling AccessKey and SecretKey 2025-06-21 20:38:22 +08:00
t0ng7u cfef9a3b09 Merge remote-tracking branch 'origin/main' into alpha 2025-06-21 20:25:35 +08:00
t0ng7u 8248185e33 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
t0ng7u 277ffdcb76 🎨 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
neotf 984152c772 fix(quota): refine cache token calculation for OpenRouter channel type 2025-06-18 20:11:48 +08:00
neotf 685a65c722 format 2025-06-18 19:54:20 +08:00
neotf ecacff87ad 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 c787f59822 Merge branch 'main' into feat-04 2025-06-18 15:20:24 +08:00
Papersnake d8790963ae feat: openrouter format for claude request 2025-06-16 17:43:39 +08:00
neotf 342af15f73 format 2025-06-11 14:00:32 +08:00
neotf 0a3414bf26 format 2025-06-11 13:56:44 +08:00
neotf 1cff048cb7 Merge branch 'main' into feat-04 2025-06-11 13:55:47 +08:00
neotf 1ce5c34703 Merge branch 'main' into feat-04 2025-06-05 20:35:47 +08:00
neotf dde8c49a2e feat: enhance cache_create_tokens calculation for OpenRouter 2025-05-29 22:47:02 +08:00
neotf c0df38767c feat: enhance token usage details for upstream OpenRouter 2025-05-29 00:55:57 +08:00
JoeyLearnsToCode 607d5fc25e feat: 渠道编辑页增加复制所有模型功能 2025-05-19 19:33:29 +08:00
57 changed files with 2333 additions and 918 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# 设置流式一次回复的超时时间
# STREAMING_TIMEOUT=90
# STREAMING_TIMEOUT=120
# 节点类型
+1 -1
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`
+1 -1
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`
+1
View File
@@ -7,4 +7,5 @@ const (
ContextKeyUserStatus = "user_status"
ContextKeyUserEmail = "user_email"
ContextKeyUserGroup = "user_group"
ContextKeyUsingGroup = "group"
)
+1 -1
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信息
+1 -1
View File
@@ -171,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
}
+125 -28
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": "",
+163 -11
View File
@@ -3,6 +3,7 @@ package controller
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
@@ -43,7 +44,17 @@ func FetchUpstreamRatios(c *gin.Context) {
var upstreams []dto.UpstreamDTO
if len(req.ChannelIDs) > 0 {
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))
@@ -57,6 +68,7 @@ func FetchUpstreamRatios(c *gin.Context) {
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: "",
@@ -93,43 +105,125 @@ func FetchUpstreamRatios(c *gin.Context) {
}
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: chItem.Name, Err: 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: chItem.Name, Err: 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: chItem.Name, Err: 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 map[string]any `json:"data"`
Message string `json:"message"`
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: chItem.Name, Err: err.Error()}
ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
return
}
if !body.Success {
ch <- upstreamResult{Name: chItem.Name, Err: body.Message}
ch <- upstreamResult{Name: uniqueName, Err: body.Message}
return
}
ch <- upstreamResult{Name: chItem.Name, Data: body.Data}
// 尝试按 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)
}
@@ -202,6 +296,43 @@ func buildDifferences(localData map[string]any, successfulChannels []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
@@ -214,6 +345,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
}
upstreamValues := make(map[string]interface{})
confidenceValues := make(map[string]bool)
hasUpstreamValue := false
hasDifference := false
@@ -241,6 +373,8 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
}
upstreamValues[channel.name] = upstreamValue
confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
}
shouldInclude := false
@@ -262,6 +396,7 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
differences[modelName][ratioType] = dto.DifferenceItem{
Current: localValue,
Upstreams: upstreamValues,
Confidence: confidenceValues,
}
}
}
@@ -283,9 +418,26 @@ func buildDifferences(localData map[string]any, successfulChannels []struct {
for chName := range item.Upstreams {
if !channelHasDiff[chName] {
delete(item.Upstreams, chName)
delete(item.Confidence, chName)
}
}
differences[modelName][ratioType] = item
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)
}
}
+29
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,
})
}
+1
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"`
+3 -1
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 {
+3 -14
View File
@@ -1,18 +1,7 @@
package dto
// UpstreamDTO 提交到后端同步倍率的上游渠道信息
// Endpoint 可以为空,后端会默认使用 /api/ratio_config
// BaseURL 必须以 http/https 开头,不要以 / 结尾
// 例如: https://api.example.com
// Endpoint: /api/ratio_config
// 提交示例:
// {
// "name": "openai",
// "base_url": "https://api.openai.com",
// "endpoint": "/ratio_config"
// }
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"`
@@ -20,6 +9,7 @@ type UpstreamDTO struct {
type UpstreamRequest struct {
ChannelIDs []int64 `json:"channel_ids"`
Upstreams []UpstreamDTO `json:"upstreams"`
Timeout int `json:"timeout"`
}
@@ -37,10 +27,9 @@ type TestResult struct {
type DifferenceItem struct {
Current interface{} `json:"current"`
Upstreams map[string]interface{} `json:"upstreams"`
Confidence map[string]bool `json:"confidence"`
}
// SyncableChannel 可同步的渠道信息(base_url 不为空)
type SyncableChannel struct {
ID int `json:"id"`
Name string `json:"name"`
+1 -1
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 {
+34
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
}
+16
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) {
+24 -20
View File
@@ -78,26 +78,7 @@ 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.UpstreamModelName
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
@@ -150,6 +131,29 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
}
}
}
// 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 {
+5
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
+3 -3
View File
@@ -69,8 +69,8 @@ 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, ",")
// 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])
@@ -264,7 +264,7 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
}
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
parts := strings.Split(apiKey, ",")
parts := strings.Split(apiKey, "|")
if len(parts) != 2 {
return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'")
}
+3 -4
View File
@@ -65,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
@@ -219,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
@@ -239,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,
+46 -3
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"
@@ -76,6 +77,33 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay
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) {
req, err := getAndValidateGeminiRequest(c)
if err != nil {
@@ -107,12 +135,27 @@ func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
relayInfo.SetPromptTokens(promptTokens)
} else {
promptTokens := getGeminiInputTokens(req, relayInfo)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
}
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)
+35 -4
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 {
+28 -61
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
+1 -1
View File
@@ -541,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)
}
+19 -5
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"
)
/*
@@ -51,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)
@@ -121,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)
}
+1
View File
@@ -125,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())
+6 -3
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)),
+11
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
}
+39 -6
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 {
+3 -3
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
}
+3 -3
View File
@@ -501,13 +501,13 @@ 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
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, true
return 4, false
}
return 3.5 / 0.15, true
return 3.5 / 0.15, false
}
if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") {
return 4, true
+25 -19
View File
@@ -113,25 +113,31 @@ const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadK
return (
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
<Timeline mode="alternate">
{processedAnnouncements.map((item, idx) => (
<Timeline.Item
key={idx}
type={item.type}
time={item.time}
className={item.isUnread ? '' : ''}
>
<div>
{item.isUnread ? (
<span className="shine-text">
{item.content}
</span>
) : (
item.content
)}
{item.extra && <div className="text-xs text-gray-500">{item.extra}</div>}
</div>
</Timeline.Item>
))}
{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>
);
@@ -1,115 +1,183 @@
import React, { useState } from 'react';
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { isMobile } from '../../helpers';
import {
Modal,
Transfer,
Table,
Input,
Space,
Checkbox,
Avatar,
Highlight,
Select,
Tag,
} from '@douyinfe/semi-ui';
import { IconClose } from '@douyinfe/semi-icons';
import { IconSearch } from '@douyinfe/semi-icons';
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
const CHANNEL_STATUS_CONFIG = {
1: { color: 'green', text: '启用' },
2: { color: 'red', text: '禁用' },
3: { color: 'amber', text: '自禁' },
default: { color: 'grey', text: '未知' }
};
const getChannelStatusConfig = (status) => {
return CHANNEL_STATUS_CONFIG[status] || CHANNEL_STATUS_CONFIG.default;
};
export default function ChannelSelectorModal({
t,
const ChannelSelectorModal = forwardRef(({
visible,
onCancel,
onOk,
allChannels = [],
selectedChannelIds = [],
allChannels,
selectedChannelIds,
setSelectedChannelIds,
channelEndpoints,
updateChannelEndpoint,
}) {
t,
}, ref) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const ChannelInfo = ({ item, showEndpoint = false, isSelected = false }) => {
const channelId = item.key || item.value;
const currentEndpoint = channelEndpoints[channelId];
const baseUrl = item._originalData?.base_url || '';
const status = item._originalData?.status || 0;
const statusConfig = getChannelStatusConfig(status);
const [filteredData, setFilteredData] = useState([]);
return (
<>
<Avatar color={statusConfig.color} size="small">
{statusConfig.text}
</Avatar>
<div className="info">
<div className="name">
{isSelected ? (
item.label
) : (
<Highlight sourceString={item.label} searchWords={[searchText]} />
)}
</div>
<div className="email" style={showEndpoint ? { display: 'flex', alignItems: 'center', gap: '4px' } : {}}>
<span className="text-xs text-gray-500 truncate max-w-[200px]" title={baseUrl}>
{isSelected ? (
baseUrl
) : (
<Highlight sourceString={baseUrl} searchWords={[searchText]} />
)}
</span>
{showEndpoint && (
<Input
size="small"
value={currentEndpoint}
onChange={(value) => updateChannelEndpoint(channelId, value)}
placeholder="/api/ratio_config"
className="flex-1 text-xs"
style={{ fontSize: '12px' }}
/>
)}
{isSelected && !showEndpoint && (
<span className="text-xs text-gray-700 font-mono bg-gray-100 px-2 py-1 rounded ml-2">
{currentEndpoint}
</span>
)}
</div>
</div>
</>
);
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 renderSourceItem = (item) => {
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 className="components-transfer-source-item" key={item.key}>
<Checkbox
onChange={item.onChange}
checked={item.checked}
style={{ height: 52, alignItems: 'center' }}
>
<ChannelInfo item={item} showEndpoint={true} />
</Checkbox>
<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 renderSelectedItem = (item) => {
return (
<div className="components-transfer-selected-item" key={item.key}>
<ChannelInfo item={item} isSelected={true} />
<IconClose style={{ cursor: 'pointer' }} onClick={item.onRemove} />
</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 channelFilter = (input, item) => {
const searchLower = input.toLowerCase();
return item.label.toLowerCase().includes(searchLower) ||
(item._originalData?.base_url || '').toLowerCase().includes(searchLower);
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 (
@@ -118,26 +186,51 @@ export default function ChannelSelectorModal({
onCancel={onCancel}
onOk={onOk}
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
width={1000}
size={isMobile() ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
<Space vertical style={{ width: '100%' }}>
<Transfer
style={{ width: '100%' }}
dataSource={allChannels}
value={selectedChannelIds}
onChange={setSelectedChannelIds}
renderSourceItem={renderSourceItem}
renderSelectedItem={renderSelectedItem}
filter={channelFilter}
inputProps={{ placeholder: t('搜索渠道名称或地址') }}
onSearch={setSearchText}
emptyContent={{
left: t('暂无渠道'),
right: t('暂无选择'),
search: t('无搜索结果'),
<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;
+10 -5
View File
@@ -84,7 +84,16 @@ const RatioSetting = () => {
<Card style={{ marginTop: '10px' }}>
<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
@@ -106,10 +115,6 @@ const RatioSetting = () => {
</Tabs.TabPane>
</Tabs>
</Card>
{/* 分组倍率设置 */}
<Card style={{ marginTop: '10px' }}>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Card>
</Spin>
);
};
+271 -79
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>
)}
+97 -83
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={
+17 -5
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={
+20 -6
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,
+17 -4
View File
@@ -47,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;
@@ -471,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);
@@ -650,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 ? (
@@ -665,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" />
@@ -763,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={
+117 -18
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,
+21 -7
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}} 条', {
+2
View File
@@ -131,3 +131,5 @@ export const CHANNEL_OPTIONS = [
label: '可灵',
},
];
export const MODEL_TABLE_PAGE_SIZE = 10;
+3 -1
View File
@@ -1,3 +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 DEFAULT_ENDPOINT = '/api/ratio_config';
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';
+29
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);
}
+34
View File
@@ -0,0 +1,34 @@
import { useState, useEffect, useCallback } from 'react';
import { getTableCompactMode, setTableCompactMode } from '../helpers';
import { TABLE_COMPACT_MODES_KEY } from '../constants';
/**
* 自定义 Hook管理表格紧凑/自适应模式
* 返回 [compactMode, setCompactMode]
* 内部使用 localStorage 保存状态并监听 storage 事件保持多标签页同步
*/
export function useTableCompactMode(tableKey = 'global') {
const [compactMode, setCompactModeState] = useState(() => getTableCompactMode(tableKey));
const setCompactMode = useCallback((value) => {
setCompactModeState(value);
setTableCompactMode(value, tableKey);
}, [tableKey]);
useEffect(() => {
const handleStorage = (e) => {
if (e.key === TABLE_COMPACT_MODES_KEY) {
try {
const modes = JSON.parse(e.newValue || '{}');
setCompactModeState(!!modes[tableKey]);
} catch {
// ignore parse error
}
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, [tableKey]);
return [compactMode, setCompactMode];
}
+46 -4
View File
@@ -139,7 +139,7 @@
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
"搜索渠道的 ID,名称,密钥API地址 ...": "Search channel ID, name, key and Base URL...",
"渠道ID,名称,密钥API地址": "Channel ID, name, key, Base URL",
"名称": "Name",
"分组": "Group",
"类型": "Type",
@@ -428,6 +428,7 @@
"填入基础模型": "Fill in the basic model",
"填入所有模型": "Fill in all models",
"清除所有模型": "Clear all models",
"复制所有模型": "Copy all models",
"密钥": "Key",
"请输入密钥": "Please enter the key",
"批量创建": "Batch Create",
@@ -813,7 +814,16 @@
"复制所选令牌": "Copy selected token",
"请至少选择一个令牌!": "Please select at least one token!",
"管理员未设置查询页链接": "The administrator has not set the query page link",
"复制所选令牌到剪贴板": "Copy selected token to clipboard",
"批量删除令牌": "Batch delete token",
"确定要删除所选的 {{count}} 个令牌吗?": "Are you sure you want to delete the selected {{count}} tokens?",
"删除所选令牌": "Delete selected token",
"请先选择要删除的令牌!": "Please select the token to be deleted!",
"已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
"删除失败": "Delete failed",
"复制令牌": "Copy token",
"请选择你的复制方式": "Please select your copy method",
"名称+密钥": "Name + key",
"仅密钥": "Only key",
"查看API地址": "View API address",
"打开查询页": "Open query page",
"时间(仅显示近3天)": "Time (only displays the last 3 days)",
@@ -1263,7 +1273,7 @@
" 吗?": "?",
"修改子渠道优先级": "Modify sub-channel priority",
"确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ",
"分组设置": "Group settings",
"分组倍率设置": "Group ratio settings",
"用户可选分组": "User selectable groups",
"保存分组倍率设置": "Save group ratio settings",
"模型倍率设置": "Model ratio settings",
@@ -1615,6 +1625,7 @@
"编辑公告": "Edit Notice",
"公告内容": "Notice Content",
"请输入公告内容": "Please enter the notice content",
"请输入公告内容(支持 Markdown/HTML": "Please enter the notice content (supports Markdown/HTML)",
"发布日期": "Publish Date",
"请选择发布日期": "Please select the publish date",
"发布时间": "Publish Time",
@@ -1630,6 +1641,7 @@
"请输入问题标题": "Please enter the question title",
"回答内容": "Answer Content",
"请输入回答内容": "Please enter the answer content",
"请输入回答内容(支持 Markdown/HTML": "Please enter the answer content (supports Markdown/HTML)",
"确定要删除此问答吗?": "Are you sure you want to delete this FAQ?",
"系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)",
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
@@ -1701,5 +1713,35 @@
"充值分组倍率": "Recharge group ratio",
"充值方式设置": "Recharge method settings",
"更新支付设置": "Update payment settings",
"通知": "Notice"
"通知": "Notice",
"源地址": "Source address",
"同步接口": "Synchronization interface",
"置信度": "Confidence",
"谨慎": "Cautious",
"该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution",
"可信": "Reliable",
"所有上游数据均可信": "All upstream data is reliable",
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
"按倍率类型筛选": "Filter by ratio type",
"内容": "Content",
"放大编辑": "Expand editor",
"编辑公告内容": "Edit announcement content",
"自适应列表": "Adaptive list",
"紧凑列表": "Compact list",
"仅显示矛盾倍率": "Only show conflicting ratios",
"矛盾": "Conflict",
"确认冲突项修改": "Confirm conflict item modification",
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
"当前计费": "Current billing",
"修改为": "Modify to",
"状态筛选": "Status filter",
"没有模型可以复制": "No models to copy",
"模型列表已复制到剪贴板": "Model list copied to clipboard",
"复制失败": "Copy failed",
"复制已选": "Copy selected",
"选择成功": "Selection successful",
"暂无成功模型": "No successful models",
"请先选择模型!": "Please select a model first!",
"已复制 ${count} 个模型": "Copied ${count} models",
"复制失败,请手动复制": "Copy failed, please copy manually"
}
+2
View File
@@ -375,6 +375,7 @@ code {
}
/* 隐藏卡片内容区域的滚动条 */
.model-test-scroll,
.card-content-scroll,
.model-settings-scroll,
.thinking-content-scroll,
@@ -385,6 +386,7 @@ code {
scrollbar-width: none;
}
.model-test-scroll::-webkit-scrollbar,
.card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
+58 -27
View File
@@ -26,7 +26,7 @@ import {
Card,
Tag,
} from '@douyinfe/semi-ui';
import { getChannelModels } from '../../helpers';
import { getChannelModels, copy } from '../../helpers';
import {
IconSave,
IconClose,
@@ -64,6 +64,8 @@ function type2secretPrompt(type) {
return '按照如下格式输入:AppId|SecretId|SecretKey';
case 33:
return '按照如下格式输入:Ak|Sk|Region';
case 50:
return '按照如下格式输入: AccessKey|SecretKey';
default:
return '请输入渠道对应的鉴权密钥';
}
@@ -109,6 +111,10 @@ const EditChannel = (props) => {
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const handleInputChange = (name, value) => {
if (name === 'models' && Array.isArray(value)) {
value = Array.from(new Set(value.map((m) => (m || '').trim())));
}
if (name === 'base_url' && value.endsWith('/v1')) {
Modal.confirm({
title: '警告',
@@ -263,10 +269,14 @@ const EditChannel = (props) => {
const fetchModels = async () => {
try {
let res = await API.get(`/api/channel/models`);
let localModelOptions = res.data.data.map((model) => ({
label: model.id,
value: model.id,
}));
const localModelOptions = res.data.data.map((model) => {
const id = (model.id || '').trim();
return {
key: id,
label: id,
value: id,
};
});
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
setBasicModels(
@@ -298,27 +308,29 @@ const EditChannel = (props) => {
}
};
useEffect(() => {
// 使用 Map 来避免重复,以 value 为键
const modelMap = new Map();
// 先添加原始模型选项
originModelOptions.forEach(option => {
modelMap.set(option.value, option);
});
// 再添加当前选中的模型(如果不存在)
inputs.models.forEach(model => {
if (!modelMap.has(model)) {
modelMap.set(model, {
label: model,
value: model,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
const modelMap = new Map();
originModelOptions.forEach(option => {
const v = (option.value || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, option);
}
});
inputs.models.forEach(model => {
const v = (model || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, {
key: v,
label: v,
value: v,
});
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
useEffect(() => {
fetchModels().then();
@@ -401,7 +413,7 @@ useEffect(() => {
localModels.push(model);
localModelOptions.push({
key: model,
text: model,
label: model,
value: model,
});
addedModels.push(model);
@@ -830,6 +842,25 @@ useEffect(() => {
>
{t('清除所有模型')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (inputs.models.length === 0) {
showInfo(t('没有模型可以复制'));
return;
}
try {
copy(inputs.models.join(','));
showSuccess(t('模型列表已复制到剪贴板'));
} catch (error) {
showError(t('复制失败'));
}
}}
size="large"
className="!rounded-lg"
>
{t('复制所有模型')}
</Button>
</div>
<div>
+25 -5
View File
@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState, useMemo, useCallback }
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import { useNavigate } from 'react-router-dom';
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle } from 'lucide-react';
import { marked } from 'marked';
import {
Card,
@@ -1267,10 +1268,27 @@ const Detail = (props) => {
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
>
{announcementData.length > 0 ? (
<Timeline
mode="alternate"
dataSource={announcementData}
/>
<Timeline mode="alternate">
{announcementData.map((item, idx) => (
<Timeline.Item
key={idx}
type={item.type || 'default'}
time={item.time}
>
<div>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
/>
{item.extra && (
<div
className="text-xs text-gray-500"
dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
/>
)}
</div>
</Timeline.Item>
))}
</Timeline>
) : (
<div className="flex justify-center items-center py-8">
<Empty
@@ -1321,7 +1339,9 @@ const Detail = (props) => {
header={item.question}
itemKey={index.toString()}
>
<p>{item.answer}</p>
<div
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
/>
</Collapse.Panel>
))}
</Collapse>
+1 -1
View File
@@ -90,7 +90,7 @@ const Home = () => {
{/* 居中内容区 */}
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-semibold text-semi-color-text-0 leading-tight">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight">
{i18n.language === 'en' ? (
<>
The Unified<br />
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
Button,
Space,
@@ -9,7 +9,9 @@ import {
Divider,
Modal,
Tag,
Switch
Switch,
TextArea,
Tooltip
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -20,7 +22,8 @@ import {
Edit,
Trash2,
Save,
Bell
Bell,
Maximize2
} from 'lucide-react';
import { API, showError, showSuccess, getRelativeTime, formatDateTimeString } from '../../../helpers';
import { useTranslation } from 'react-i18next';
@@ -33,6 +36,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
const [announcementsList, setAnnouncementsList] = useState([]);
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showContentModal, setShowContentModal] = useState(false);
const [deletingAnnouncement, setDeletingAnnouncement] = useState(null);
const [editingAnnouncement, setEditingAnnouncement] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
@@ -51,6 +55,8 @@ const SettingsAnnouncements = ({ options, refresh }) => {
// 面板启用状态
const [panelEnabled, setPanelEnabled] = useState(true);
const formApiRef = useRef(null);
const typeOptions = [
{ value: 'default', label: t('默认') },
{ value: 'ongoing', label: t('进行中') },
@@ -76,13 +82,16 @@ const SettingsAnnouncements = ({ options, refresh }) => {
dataIndex: 'content',
key: 'content',
render: (text) => (
<div style={{
maxWidth: '300px',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap'
}}>
{text}
</div>
<Tooltip content={text} position='topLeft' showArrow>
<div style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{text}
</div>
</Tooltip>
)
},
{
@@ -121,13 +130,17 @@ const SettingsAnnouncements = ({ options, refresh }) => {
dataIndex: 'extra',
key: 'extra',
render: (text) => (
<div style={{
maxWidth: '200px',
wordBreak: 'break-word',
color: 'var(--semi-color-text-2)'
}}>
{text || '-'}
</div>
<Tooltip content={text || '-'} showArrow>
<div style={{
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-2)'
}}>
{text || '-'}
</div>
</Tooltip>
)
},
{
@@ -472,16 +485,31 @@ const SettingsAnnouncements = ({ options, refresh }) => {
className="rounded-xl"
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={announcementForm} key={editingAnnouncement ? editingAnnouncement.id : 'new'}>
<Form
layout='vertical'
initValues={announcementForm}
key={editingAnnouncement ? editingAnnouncement.id : 'new'}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.TextArea
field='content'
label={t('公告内容')}
placeholder={t('请输入公告内容')}
placeholder={t('请输入公告内容(支持 Markdown/HTML')}
maxCount={500}
rows={3}
rules={[{ required: true, message: t('请输入公告内容') }]}
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
/>
<Button
theme='light'
type='tertiary'
size='small'
icon={<Maximize2 size={14} />}
style={{ marginBottom: 16 }}
onClick={() => setShowContentModal(true)}
>
{t('放大编辑')}
</Button>
<Form.DatePicker
field='publishDate'
label={t('发布日期')}
@@ -523,6 +551,33 @@ const SettingsAnnouncements = ({ options, refresh }) => {
>
<Text>{t('确定要删除此公告吗?')}</Text>
</Modal>
{/* 公告内容放大编辑 Modal */}
<Modal
title={t('编辑公告内容')}
visible={showContentModal}
onOk={() => {
// 将内容同步到表单
if (formApiRef.current) {
formApiRef.current.setValue('content', announcementForm.content);
}
setShowContentModal(false);
}}
onCancel={() => setShowContentModal(false)}
okText={t('确定')}
cancelText={t('取消')}
className="rounded-xl"
width={800}
>
<TextArea
value={announcementForm.content}
placeholder={t('请输入公告内容(支持 Markdown/HTML')}
maxCount={500}
rows={15}
style={{ width: '100%' }}
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
/>
</Modal>
</>
);
};
+25 -17
View File
@@ -8,7 +8,8 @@ import {
Empty,
Divider,
Modal,
Switch
Switch,
Tooltip
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
@@ -54,13 +55,17 @@ const SettingsFAQ = ({ options, refresh }) => {
dataIndex: 'question',
key: 'question',
render: (text) => (
<div style={{
maxWidth: '300px',
wordBreak: 'break-word',
fontWeight: 'bold'
}}>
{text}
</div>
<Tooltip content={text} showArrow>
<div style={{
maxWidth: '300px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bold'
}}>
{text}
</div>
</Tooltip>
)
},
{
@@ -68,14 +73,17 @@ const SettingsFAQ = ({ options, refresh }) => {
dataIndex: 'answer',
key: 'answer',
render: (text) => (
<div style={{
maxWidth: '400px',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
color: 'var(--semi-color-text-1)'
}}>
{text}
</div>
<Tooltip content={text} showArrow>
<div style={{
maxWidth: '400px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--semi-color-text-1)'
}}>
{text}
</div>
</Tooltip>
)
},
{
@@ -416,7 +424,7 @@ const SettingsFAQ = ({ options, refresh }) => {
<Form.TextArea
field='answer'
label={t('回答内容')}
placeholder={t('请输入回答内容')}
placeholder={t('请输入回答内容(支持 Markdown/HTML')}
maxCount={1000}
rows={6}
rules={[{ required: true, message: t('请输入回答内容') }]}
+120 -122
View File
@@ -96,133 +96,131 @@ export default function GroupRatioSettings(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('分组设置')}>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('分组倍率')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
extraText={t(
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5test 分组的倍率为 1',
)}
field={'GroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)}
field={'UserUsableGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, UserUsableGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('分组特殊倍率')}
placeholder={t('为一个 JSON 文本')}
extraText={t(
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
)}
field={'GroupGroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('自动分组auto,从第一个开始选择')}
placeholder={t('为一个 JSON 文本')}
field={'AutoGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('分组倍率')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
extraText={t(
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5test 分组的倍率为 1',
)}
field={'GroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)}
field={'UserUsableGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, UserUsableGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('分组特殊倍率')}
placeholder={t('为一个 JSON 文本')}
extraText={t(
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
)}
field={'GroupGroupRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('自动分组auto,从第一个开始选择')}
placeholder={t('为一个 JSON 文本')}
field={'AutoGroups'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
// First check if it's valid JSON
try {
const parsed = JSON.parse(value);
// First check if it's valid JSON
try {
const parsed = JSON.parse(value);
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
return parsed.every(item => typeof item === 'string');
} catch (error) {
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
},
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
// Check if every element is a string
return parsed.every(item => typeof item === 'string');
} catch (error) {
return false;
}
},
]}
onChange={(value) =>
setInputs({ ...inputs, AutoGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t(
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
}
/>
</Col>
</Row>
</Form.Section>
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
},
]}
onChange={(value) =>
setInputs({ ...inputs, AutoGroups: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t(
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
}
/>
</Col>
</Row>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
</Spin>
+99 -101
View File
@@ -118,107 +118,105 @@ export default function ModelRatioSettings(props) {
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型固定价格')}
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
placeholder={t(
'为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀',
)}
field={'ModelPrice'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelPrice: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'ModelRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('提示缓存倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'CacheRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, CacheRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型补全倍率(仅对自定义模型有效')}
extraText={t('仅对自定义模型有效')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'CompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, CompletionRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t('暴露倍率接口')}
field={'ExposeRatioEnabled'}
onChange={(value) =>
setInputs({ ...inputs, ExposeRatioEnabled: value })
}
/>
</Col>
</Row>
</Form.Section>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型固定价格')}
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
placeholder={t(
'为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀',
)}
field={'ModelPrice'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelPrice: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'ModelRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ModelRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('提示缓存倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'CacheRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, CacheRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('模型补全倍率(仅对自定义模型有效)')}
extraText={t('仅对自定义模型有效')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
field={'CompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, CompletionRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch
label={t('暴露倍率接口')}
field={'ExposeRatioEnabled'}
onChange={(value) =>
setInputs({ ...inputs, ExposeRatioEnabled: value })
}
/>
</Col>
</Row>
</Form>
<Space>
<Button onClick={onSubmit}>{t('保存模型倍率设置')}</Button>
@@ -8,7 +8,9 @@ import {
Form,
Space,
RadioGroup,
Radio
Radio,
Checkbox,
Tag
} from '@douyinfe/semi-ui';
import {
IconDelete,
@@ -30,6 +32,7 @@ export default function ModelSettingsVisualEditor(props) {
const [loading, setLoading] = useState(false);
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
const [conflictOnly, setConflictOnly] = useState(false);
const formRef = useRef(null);
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();
@@ -47,13 +50,19 @@ export default function ModelSettingsVisualEditor(props) {
...Object.keys(completionRatio),
]);
const modelData = Array.from(modelNames).map((name) => ({
name,
price: modelPrice[name] === undefined ? '' : modelPrice[name],
ratio: modelRatio[name] === undefined ? '' : modelRatio[name],
completionRatio:
completionRatio[name] === undefined ? '' : completionRatio[name],
}));
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
price,
ratio,
completionRatio: comp,
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
};
});
setModels(modelData);
} catch (error) {
@@ -69,11 +78,13 @@ export default function ModelSettingsVisualEditor(props) {
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText
const filteredModels = models.filter((model) => {
const keywordMatch = searchText
? model.name.toLowerCase().includes(searchText.toLowerCase())
: true,
);
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
@@ -152,6 +163,16 @@ export default function ModelSettingsVisualEditor(props) {
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{text}
{record.hasConflict && (
<Tag color='red' shape='circle' className='ml-2'>
{t('矛盾')}
</Tag>
)}
</span>
),
},
{
title: t('模型固定价格'),
@@ -219,9 +240,13 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) =>
prev.map((model) =>
model.name === name ? { ...model, [field]: value } : model,
),
prev.map((model) => {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
};
@@ -296,16 +321,18 @@ export default function ModelSettingsVisualEditor(props) {
if (existingModelIndex >= 0) {
// Update existing model
setModels((prev) =>
prev.map((model, index) =>
index === existingModelIndex
? {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
}
: model,
),
prev.map((model, index) => {
if (index !== existingModelIndex) return model;
const updated = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
setVisible(false);
showSuccess(t('更新成功'));
@@ -317,15 +344,17 @@ export default function ModelSettingsVisualEditor(props) {
return;
}
setModels((prev) => [
{
setModels((prev) => {
const newModel = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
},
...prev,
]);
};
newModel.hasConflict =
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
showSuccess(t('添加成功'));
}
@@ -426,7 +455,17 @@ export default function ModelSettingsVisualEditor(props) {
setCurrentPage(1);
}}
style={{ width: 200 }}
showClear
/>
<Checkbox
checked={conflictOnly}
onChange={(e) => {
setConflictOnly(e.target.checked);
setCurrentPage(1);
}}
>
{t('仅显示矛盾倍率')}
</Checkbox>
</Space>
<Table
columns={columns}
+333 -76
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Button,
Table,
@@ -7,13 +7,18 @@ import {
Checkbox,
Form,
Input,
Tooltip,
Select,
Modal,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import {
RefreshCcw,
CheckSquare,
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
import { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next';
import {
@@ -22,6 +27,35 @@ import {
} from '@douyinfe/semi-illustrations';
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
const columns = [
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('模型'), dataIndex: 'model' },
{
title: t('当前计费'),
dataIndex: 'current',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
{
title: t('修改为'),
dataIndex: 'newVal',
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
},
];
return (
<Modal
title={t('确认冲突项修改')}
visible={visible}
onCancel={onCancel}
onOk={onOk}
size={isMobile() ? 'full-width' : 'large'}
>
<Table columns={columns} dataSource={items} pagination={false} size="small" />
</Modal>
);
}
export default function UpstreamRatioSync(props) {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
@@ -49,6 +83,19 @@ export default function UpstreamRatioSync(props) {
// 搜索相关状态
const [searchKeyword, setSearchKeyword] = useState('');
// 倍率类型过滤
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
// 冲突确认弹窗相关
const [confirmVisible, setConfirmVisible] = useState(false);
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
const channelSelectorRef = React.useRef(null);
useEffect(() => {
setCurrentPage(1);
}, [ratioTypeFilter, searchKeyword]);
const fetchAllChannels = async () => {
setLoading(true);
try {
@@ -67,11 +114,16 @@ export default function UpstreamRatioSync(props) {
setAllChannels(transferData);
const initialEndpoints = {};
transferData.forEach(channel => {
initialEndpoints[channel.key] = DEFAULT_ENDPOINT;
// 合并已有 endpoints,避免每次打开弹窗都重置
setChannelEndpoints(prev => {
const merged = { ...prev };
transferData.forEach(channel => {
if (!merged[channel.key]) {
merged[channel.key] = DEFAULT_ENDPOINT;
}
});
return merged;
});
setChannelEndpoints(initialEndpoints);
} else {
showError(res.data.message);
}
@@ -99,8 +151,15 @@ export default function UpstreamRatioSync(props) {
const fetchRatiosFromChannels = async (channelList) => {
setSyncLoading(true);
const upstreams = channelList.map(ch => ({
id: ch.id,
name: ch.name,
base_url: ch.base_url,
endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT,
}));
const payload = {
channel_ids: channelList.map(ch => parseInt(ch.id)),
upstreams: upstreams,
timeout: 10,
};
@@ -134,15 +193,30 @@ export default function UpstreamRatioSync(props) {
}
};
const selectValue = (model, ratioType, value) => {
setResolutions(prev => ({
...prev,
[model]: {
...prev[model],
[ratioType]: value,
},
}));
};
function getBillingCategory(ratioType) {
return ratioType === 'model_price' ? 'price' : 'ratio';
}
const selectValue = useCallback((model, ratioType, value) => {
const category = getBillingCategory(ratioType);
setResolutions(prev => {
const newModelRes = { ...(prev[model] || {}) };
Object.keys(newModelRes).forEach((rt) => {
if (getBillingCategory(rt) !== category) {
delete newModelRes[rt];
}
});
newModelRes[ratioType] = value;
return {
...prev,
[model]: newModelRes,
};
});
}, [setResolutions]);
const applySync = async () => {
const currentRatios = {
@@ -152,19 +226,100 @@ export default function UpstreamRatioSync(props) {
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
const conflicts = [];
const getLocalBillingCategory = (model) => {
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
if (currentRatios.ModelRatio[model] !== undefined ||
currentRatios.CompletionRatio[model] !== undefined ||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
return null;
};
const findSourceChannel = (model, ratioType, value) => {
if (differences[model] && differences[model][ratioType]) {
const upMap = differences[model][ratioType].upstreams || {};
const entry = Object.entries(upMap).find(([_, v]) => v === value);
if (entry) return entry[0];
}
return t('未知');
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const localCat = getLocalBillingCategory(model);
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
if (localCat && localCat !== newCat) {
const currentDesc = localCat === 'price'
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
let newDesc = '';
if (newCat === 'price') {
newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
} else {
const newModelRatio = ratios['model_ratio'] ?? '-';
const newCompRatio = ratios['completion_ratio'] ?? '-';
newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
}
const channels = Object.entries(ratios)
.map(([rt, val]) => findSourceChannel(model, rt, val))
.filter((v, idx, arr) => arr.indexOf(v) === idx)
.join(', ');
conflicts.push({
channel: channels,
model,
current: currentDesc,
newVal: newDesc,
});
}
});
if (conflicts.length > 0) {
setConflictItems(conflicts);
setConfirmVisible(true);
return;
}
await performSync(currentRatios);
};
const performSync = useCallback(async (currentRatios) => {
const finalRatios = {
ModelRatio: { ...currentRatios.ModelRatio },
CompletionRatio: { ...currentRatios.CompletionRatio },
CacheRatio: { ...currentRatios.CacheRatio },
ModelPrice: { ...currentRatios.ModelPrice },
};
Object.entries(resolutions).forEach(([model, ratios]) => {
const selectedTypes = Object.keys(ratios);
const hasPrice = selectedTypes.includes('model_price');
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
if (hasPrice) {
delete finalRatios.ModelRatio[model];
delete finalRatios.CompletionRatio[model];
delete finalRatios.CacheRatio[model];
}
if (hasRatio) {
delete finalRatios.ModelPrice[model];
}
Object.entries(ratios).forEach(([ratioType, value]) => {
const optionKey = ratioType
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
currentRatios[optionKey][model] = parseFloat(value);
finalRatios[optionKey][model] = parseFloat(value);
});
});
setLoading(true);
try {
const updates = Object.entries(currentRatios).map(([key, value]) =>
const updates = Object.entries(finalRatios).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
@@ -204,7 +359,7 @@ export default function UpstreamRatioSync(props) {
} finally {
setLoading(false);
}
};
}, [resolutions, props.options, props.refresh]);
const getCurrentPageData = (dataSource) => {
const startIndex = (currentPage - 1) * pageSize;
@@ -215,13 +370,15 @@ export default function UpstreamRatioSync(props) {
const renderHeader = () => (
<div className="flex flex-col w-full">
<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-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<Button
icon={<RefreshCcw size={14} />}
className="!rounded-full w-full md:w-auto mt-2"
onClick={() => {
setModalVisible(true);
fetchAllChannels();
if (allChannels.length === 0) {
fetchAllChannels();
}
}}
>
{t('选择同步渠道')}
@@ -243,14 +400,30 @@ export default function UpstreamRatioSync(props) {
);
})()}
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型名称')}
value={searchKeyword}
onChange={setSearchKeyword}
className="!rounded-full w-full md:w-64 mt-2"
showClear
/>
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2">
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型名称')}
value={searchKeyword}
onChange={setSearchKeyword}
className="!rounded-full w-full sm:w-64"
showClear
/>
<Select
placeholder={t('按倍率类型筛选')}
value={ratioTypeFilter}
onChange={setRatioTypeFilter}
className="!rounded-full w-full sm:w-48"
showClear
onClear={() => setRatioTypeFilter('')}
>
<Select.Option value="model_ratio">{t('模型倍率')}</Select.Option>
<Select.Option value="completion_ratio">{t('补全倍率')}</Select.Option>
<Select.Option value="cache_ratio">{t('缓存倍率')}</Select.Option>
<Select.Option value="model_price">{t('固定价格')}</Select.Option>
</Select>
</div>
</div>
</div>
</div>
@@ -261,6 +434,10 @@ export default function UpstreamRatioSync(props) {
const tmp = [];
Object.entries(differences).forEach(([model, ratioTypes]) => {
const hasPrice = 'model_price' in ratioTypes;
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
const billingConflict = hasPrice && hasOtherRatio;
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
tmp.push({
key: `${model}_${ratioType}`,
@@ -268,6 +445,8 @@ export default function UpstreamRatioSync(props) {
ratioType,
current: diff.current,
upstreams: diff.upstreams,
confidence: diff.confidence || {},
billingConflict,
});
});
});
@@ -276,15 +455,20 @@ export default function UpstreamRatioSync(props) {
}, [differences]);
const filteredDataSource = useMemo(() => {
if (!searchKeyword.trim()) {
if (!searchKeyword.trim() && !ratioTypeFilter) {
return dataSource;
}
const keyword = searchKeyword.toLowerCase().trim();
return dataSource.filter(item =>
item.model.toLowerCase().includes(keyword)
);
}, [dataSource, searchKeyword]);
return dataSource.filter(item => {
const matchesKeyword = !searchKeyword.trim() ||
item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim());
const matchesRatioType = !ratioTypeFilter ||
item.ratioType === ratioTypeFilter;
return matchesKeyword && matchesRatioType;
});
}, [dataSource, searchKeyword, ratioTypeFilter]);
const upstreamNames = useMemo(() => {
const set = new Set();
@@ -320,14 +504,55 @@ export default function UpstreamRatioSync(props) {
{
title: t('倍率类型'),
dataIndex: 'ratioType',
render: (text) => {
render: (text, record) => {
const typeMap = {
model_ratio: t('模型倍率'),
completion_ratio: t('补全倍率'),
cache_ratio: t('缓存倍率'),
model_price: t('固定价格'),
};
return <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
if (record?.billingConflict) {
return (
<div className="flex items-center gap-1">
{baseTag}
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
<AlertTriangle size={14} className="text-yellow-500" />
</Tooltip>
</div>
);
}
return baseTag;
},
},
{
title: t('置信度'),
dataIndex: 'confidence',
render: (_, record) => {
const allConfident = Object.values(record.confidence || {}).every(v => v !== false);
if (allConfident) {
return (
<Tooltip content={t('所有上游数据均可信')}>
<Tag color="green" shape="circle" type="light" prefixIcon={<CheckCircle size={14} />}>
{t('可信')}
</Tag>
</Tooltip>
);
} else {
const untrustedSources = Object.entries(record.confidence || {})
.filter(([_, isConfident]) => isConfident === false)
.map(([name]) => name)
.join(', ');
return (
<Tooltip content={t('以下上游数据可能不可信:') + untrustedSources}>
<Tag color="yellow" shape="circle" type="light" prefixIcon={<AlertTriangle size={14} />}>
{t('谨慎')}
</Tag>
</Tooltip>
);
}
},
},
{
@@ -365,28 +590,27 @@ export default function UpstreamRatioSync(props) {
})();
const handleBulkSelect = (checked) => {
setResolutions((prev) => {
const newRes = { ...prev };
if (checked) {
filteredDataSource.forEach((row) => {
const upstreamVal = row.upstreams?.[upName];
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
if (checked) {
if (!newRes[row.model]) newRes[row.model] = {};
newRes[row.model][row.ratioType] = upstreamVal;
} else {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
}
selectValue(row.model, row.ratioType, upstreamVal);
}
});
return newRes;
});
} else {
setResolutions((prev) => {
const newRes = { ...prev };
filteredDataSource.forEach((row) => {
if (newRes[row.model]) {
delete newRes[row.model][row.ratioType];
if (Object.keys(newRes[row.model]).length === 0) {
delete newRes[row.model];
}
}
});
return newRes;
});
}
};
return {
@@ -404,6 +628,7 @@ export default function UpstreamRatioSync(props) {
dataIndex: upName,
render: (_, record) => {
const upstreamVal = record.upstreams?.[upName];
const isConfident = record.confidence?.[upName] !== false;
if (upstreamVal === null || upstreamVal === undefined) {
return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
@@ -416,28 +641,35 @@ export default function UpstreamRatioSync(props) {
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
return (
<Checkbox
checked={isSelected}
onChange={(e) => {
const isChecked = e.target.checked;
if (isChecked) {
selectValue(record.model, record.ratioType, upstreamVal);
} else {
setResolutions((prev) => {
const newRes = { ...prev };
if (newRes[record.model]) {
delete newRes[record.model][record.ratioType];
if (Object.keys(newRes[record.model]).length === 0) {
delete newRes[record.model];
<div className="flex items-center gap-2">
<Checkbox
checked={isSelected}
onChange={(e) => {
const isChecked = e.target.checked;
if (isChecked) {
selectValue(record.model, record.ratioType, upstreamVal);
} else {
setResolutions((prev) => {
const newRes = { ...prev };
if (newRes[record.model]) {
delete newRes[record.model][record.ratioType];
if (Object.keys(newRes[record.model]).length === 0) {
delete newRes[record.model];
}
}
}
return newRes;
});
}
}}
>
{upstreamVal}
</Checkbox>
return newRes;
});
}
}}
>
{upstreamVal}
</Checkbox>
{!isConfident && (
<Tooltip position='left' content={t('该数据可能不可信,请谨慎使用')}>
<AlertTriangle size={16} className="text-yellow-500" />
</Tooltip>
)}
</div>
);
},
};
@@ -481,6 +713,13 @@ export default function UpstreamRatioSync(props) {
setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint }));
}, []);
const handleModalClose = () => {
setModalVisible(false);
if (channelSelectorRef.current) {
channelSelectorRef.current.resetPagination();
}
};
return (
<>
<Form.Section text={renderHeader()}>
@@ -488,9 +727,10 @@ export default function UpstreamRatioSync(props) {
</Form.Section>
<ChannelSelectorModal
ref={channelSelectorRef}
t={t}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
onCancel={handleModalClose}
onOk={confirmChannelSelection}
allChannels={allChannels}
selectedChannelIds={selectedChannelIds}
@@ -498,6 +738,23 @@ export default function UpstreamRatioSync(props) {
channelEndpoints={channelEndpoints}
updateChannelEndpoint={updateChannelEndpoint}
/>
<ConflictConfirmModal
t={t}
visible={confirmVisible}
items={conflictItems}
onOk={async () => {
setConfirmVisible(false);
const curRatios = {
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
};
await performSync(curRatios);
}}
onCancel={() => setConfirmVisible(false)}
/>
</>
);
}
+10 -10
View File
@@ -45,6 +45,16 @@ const Setting = () => {
content: <OperationSetting />,
itemKey: 'operation',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<LayoutDashboard size={18} />
{t('仪表盘设置')}
</span>
),
content: <DashboardSetting />,
itemKey: 'dashboard',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
@@ -115,16 +125,6 @@ const Setting = () => {
content: <SystemSetting />,
itemKey: 'system',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<LayoutDashboard size={18} />
{t('仪表盘设置')}
</span>
),
content: <DashboardSetting />,
itemKey: 'dashboard',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
+4
View File
@@ -66,6 +66,10 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
'/mj': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/pg': {
target: 'http://localhost:3000',
changeOrigin: true,