Compare commits

...

70 Commits

Author SHA1 Message Date
Calcium-Ion e2df80118f Merge pull request #1172 from QuantumNous/alpha
feat: 0.8 version
2025-06-07 21:36:07 +08:00
Apple\Apple 91fd8b3762 🎨 feat(UI): add minimum width to progress bars in log tables
Set a minimum width of 200px for progress components in both MjLogsTable.js and TaskLogsTable.js to ensure consistent display and prevent them from becoming too narrow when table resizes.
2025-06-07 21:03:16 +08:00
Apple\Apple e9416e620c 🐛 fix(table): add rowExpandable to prevent empty rows from expanding
The Table component in LogsTable.js was previously showing expand icons for all rows, even those without any expandable content. This led to a confusing UX where users could click to expand rows but would see empty content.

This commit adds the `rowExpandable` property to the Table configuration to ensure that only rows with actual expandable content show the expand icon and can be expanded. The function checks if each record has corresponding data in the expandData object before allowing it to be expanded.
2025-06-07 16:00:55 +08:00
Apple\Apple 51ea2bac0d 💄 style(UI): standardize scrollbar styling for semi-sidesheet-body
Standardize the scrollbar appearance of semi-sidesheet-body to match the existing semi-table-body style. This change:
- Sets scrollbar width to 6px
- Applies light gray color (rgba(var(--semi-grey-2), 0.3))
- Adds subtle hover effect
- Uses 2px border radius for consistency
- Improves overall UI cohesion across components
2025-06-07 13:18:50 +08:00
creamlike1024 46e6d593a6 Merge branch 'gemini-audio-billing' into alpha 2025-06-07 12:32:20 +08:00
creamlike1024 0e9a193ed8 feat: gemini audio input billing 2025-06-07 12:26:23 +08:00
Apple\Apple 6b965995ca 🐛 fix(home): prevent empty notice modal from auto-showing
Previously, the notice modal would automatically show every day even when
the notice content was empty, causing unnecessary user interruption.

This commit modifies the logic to:
- Check notice content before showing the modal
- Only display the modal when notice content exists and is not empty
- Add proper error handling to prevent modal showing on API failures
- Improve user experience by avoiding empty notice interruptions

Changes:
- Modified useEffect in Home component to fetch notice content first
- Added API call to /api/notice before setting noticeVisible state
- Added try-catch block for graceful error handling
- Only show modal when notice data is truthy and non-empty after trimming
2025-06-07 11:34:50 +08:00
Apple\Apple f4b6ec4f50 🎨 feat(table): add conditional row expansion for LogsTable
Implement rowExpandable property to control which rows can be expanded
in the logs table. Rows are now only expandable when they have actual
expand data content, preventing empty expansion sections from being
displayed to users.

- Add rowExpandable function to check if expandData exists and has content
- Improve user experience by hiding expand functionality for rows without details
- Maintain existing expand behavior for rows with valid expansion data
2025-06-07 03:09:44 +08:00
Apple\Apple da535b9587 feat: add support for OpenAI o1/o3/o4 series models in model categorization
- Extended OpenAI model filter to include o1, o3, and o4 series models
- Updated model categorization logic to properly classify reasoning models
- Ensures all OpenAI model variants (o1-mini, o1-preview, o3, o4, etc.) are correctly grouped under OpenAI category
- Maintains backward compatibility with existing GPT and other OpenAI model series
2025-06-07 02:57:16 +08:00
Apple\Apple 85ade00e95 🎨 style(table): customize table scrollbar appearance
Enhance table scrollbar visual design with lighter and thinner styling for better user experience.

Changes:
- Add custom scrollbar styling for .semi-table-body
- Set scrollbar dimensions to 6px width/height
- Apply lighter color using rgba(var(--semi-grey-2), 0.3) with 30% opacity
- Add hover effect with 50% opacity for better interaction feedback
- Use 2px border radius for smoother appearance
- Keep scrollbar track transparent for clean look
- Utilize Semi Design color variables for theme consistency

The new scrollbar design provides a more elegant and less intrusive horizontal scrolling experience across all data tables.
2025-06-07 02:51:38 +08:00
Apple\Apple ed2677c7f5 📌 feat(table): add fixed right column to all data tables
Fix the last column (operation/detail columns) to the right side across all table components to improve user experience and ensure important actions remain visible during horizontal scrolling.

Changes:
- ChannelsTable.js: Fix operation column to right
- UsersTable.js: Fix operation column to right
- TokensTable.js: Fix operation column to right
- RedemptionsTable.js: Fix operation column to right
- LogsTable.js: Fix details column to right
- MjLogsTable.js: Fix fail reason column to right
- TaskLogsTable.js: Fix fail reason column to right

All tables now have their rightmost column fixed using Semi Design's `fixed: 'right'` property, ensuring critical information and actions are always accessible regardless of table scroll position.
2025-06-07 02:45:07 +08:00
Apple\Apple 4c92025d55 🎨 style(footer): improve theme compatibility and color consistency
Update Footer component to use semantic color variables for better theme integration:

- Replace hardcoded background color with semi-color-bg-2 for theme consistency
- Update text colors to use semantic variables (semi-color-text-0, semi-color-text-1)
- Replace hardcoded link colors with semi-color-primary for brand consistency
- Add hover effects with smooth transitions for better user experience
- Keep logo container background as gray-800 for visual stability

This ensures the footer adapts properly to different theme modes while maintaining
good readability and visual consistency across the application.
2025-06-07 01:50:53 +08:00
Apple\Apple 5a2272553c feat(home): add default system name fallback to "New API"
Add fallback display value when system_name is not available or empty.
This ensures the homepage title always shows meaningful content instead
of being blank when the system name hasn't been configured or fails to load.

- Update Home component to display "New API" as default when statusState?.status?.system_name is falsy
- Improves user experience by preventing empty title display
2025-06-07 01:14:58 +08:00
Apple\Apple 6439ff4b95 🐛 fix(ui): ensure consistent dark mode background color for footer
Fix dark mode background color rendering issue in the footer component
where the custom dark background color (#1C1F23) was not being applied
consistently across different devices due to missing !important declaration.

Changes:
- Add !important to dark mode background color class in footer
- Change `dark:bg-[#1C1F23]` to `dark:!bg-[#1C1F23]`
- Ensure footer dark mode styling is not overridden by other CSS rules

This resolves visual inconsistencies where the footer would not display
the intended dark background color in dark theme mode on certain devices
or screen configurations.
2025-06-07 01:11:04 +08:00
Apple\Apple 0546725ac3 🐛 fix(ui): ensure consistent background colors for header action buttons
Fix background color rendering issues for notification bell, theme toggle,
and language switcher buttons in the header bar. These buttons were missing
!important declarations in their CSS classes, causing inconsistent styling
across different devices where other styles could override the intended
background colors.

Changes:
- Add !important to background color classes for notification button
- Add !important to background color classes for theme toggle button
- Add !important to background color classes for language switcher button
- Ensure all header action buttons now have consistent styling matching
  the user avatar dropdown button

This resolves visual inconsistencies where these buttons would appear
without proper background colors on certain devices or screen configurations.
2025-06-07 01:07:37 +08:00
Apple\Apple 6731a2c202 🎨 refactor: Refactor dashboard statistics cards and charts layout
- Consolidate 8 individual stat cards into 4 grouped cards:
  * Account Data (Current Balance, Historical Consumption)
  * Usage Statistics (Request Count, Statistics Count)
  * Resource Consumption (Statistics Quota, Statistics Tokens)
  * Performance Metrics (Average RPM, Average TPM)

- Add gradient header backgrounds with white text for card titles:
  * Blue gradient for Account Data
  * Green gradient for Usage Statistics
  * Yellow gradient for Resource Consumption
  * Pink gradient for Performance Metrics

- Implement mini trend charts using real API data:
  * Replace mock data with actual time-series data from API
  * Hide x and y axes to show pure trend lines
  * Display trends only for metrics with available historical data
  * Remove trend charts for Current Balance, Historical Consumption, and Request Count

- Merge model analysis charts into single card:
  * Combine "Model Consumption Distribution" and "Model Call Count Ratio"
  * Use responsive grid layout (vertical on mobile, horizontal on desktop)
  * Update card title to "Model Data Analysis"

- Optimize chart configurations:
  * Hide axes, legends, and tooltips for mini trend charts
  * Maintain color consistency between metrics and trend lines
  * Improve performance by processing all trend data in single API call
2025-06-07 00:53:29 +08:00
Apple\Apple 1ed8a1e0bd 🎨style: remove header borderbottom style 2025-06-06 21:17:08 +08:00
Apple\Apple 6282eccf16 🎨 refactor: migrate sidebar inline styles to CSS classes and improve code organization
This commit improves the codebase structure by:
- Moving all inline styles from SiderBar.js to CSS classes in index.css
- Organizing CSS with clear section comments for better maintainability
- Removing unused imports and components
- Improving sidebar design with cleaner styling and consistent color management
- Restructuring CSS to group related styles together
- Adjusting sidebar width from 200px to 180px
- Replacing Text components with semantic divs for group labels
- Creating a color management function for sidebar icons
2025-06-06 20:55:52 +08:00
Apple\Apple f31144545e 🎨 feat(ui): enhance model display with category tabs and loading states
Improve the model list section in PersonalSetting component with the following enhancements:
- Add category-based tabs for filtering models by provider (OpenAI, Anthropic, etc.)
- Implement skeleton loading states using Semi UI components
- Add empty state illustrations when no models are available
- Use Semi UI design tokens for consistent styling
- Optimize display for both expanded and collapsed model lists
- Simplify some button text labels for better UI consistency
2025-06-06 04:20:57 +08:00
Calcium-Ion d8d0a6fb64 Merge pull request #1168 from RedwindA/fix/2.5-pro-128
Fix: 更新2.5 pro的思考预算范围
2025-06-06 01:59:58 +08:00
RedwindA eb23dffa04 修复2.5-pro的预算范围 2025-06-06 01:58:02 +08:00
Calcium-Ion 84a9a54af6 Merge pull request #1166 from RedwindA/fix/gemini-functionResponse
Fix: Correctly relay FunctionResponse content for Gemini API
2025-06-06 01:46:23 +08:00
RedwindA 8add0b1592 解决合并冲突 2025-06-06 01:29:06 +08:00
Calcium-Ion 7601f7f969 Merge pull request #1167 from RedwindA/feat/2.5-pro-thinkingBudget
Feat: 2.5-pro thinkingBudget
2025-06-06 01:25:13 +08:00
RedwindA d84da74a0a 硬编码旧模型,让新2.5pro均支持设置预算 2025-06-06 01:22:50 +08:00
RedwindA 2c3464c2c6 清理注释 2025-06-06 01:09:51 +08:00
RedwindA f24e2b0d40 Fix: Correctly relay FunctionResponse content for Gemini API 2025-06-06 00:56:38 +08:00
同語 508188caa6 Merge pull request #1163 from RedwindA/fix/multi-select-copy-label
fix: 修复复制多选令牌按钮的文本
2025-06-05 22:20:17 +08:00
RedwindA 24abf51b8d 修复复制多选令牌按钮的文本 2025-06-05 21:29:14 +08:00
Apple\Apple b72e641d22 Merge remote-tracking branch 'origin/main' into alpha 2025-06-05 18:59:41 +08:00
neotf a813b24184 feat: support claude cache and thinking for upstream [OpenRouter] (#983)
* feat: support claude cache for upstream [OpenRouter]

* feat: support claude thinking for upstream [OpenRouter]

* feat: reasoning is common params for OpenRouter
2025-06-05 17:35:48 +08:00
Apple\Apple 74c00e2296 Merge branch 'main' into alpha 2025-06-05 11:27:59 +08:00
Apple\Apple a1c1a336ac 📕docs: Add DeepWiki Badge in README.en.md 2025-06-05 11:27:00 +08:00
Apple\Apple 8212d391d3 Merge remote-tracking branch 'origin/main' into alpha 2025-06-05 11:19:14 +08:00
Apple\Apple 465cd77b4c 🎨 refactor: move Turnstile component to global scope in auth forms
Move the Turnstile verification component from the renderOAuthOptions method to the main render function in both LoginForm and RegisterForm components. This ensures the Turnstile verification is globally visible and accessible regardless of which authentication method the user chooses (email login/register or third-party OAuth options).

The change improves UI consistency and ensures the verification mechanism works properly across all authentication flows.
2025-06-05 11:19:00 +08:00
Calcium-Ion 28e40c90eb Merge pull request #1156 from RedwindA/deepwiki-badge
Add DeepWiki Badge in README
2025-06-05 02:19:54 +08:00
RedwindA 5bb8f2dfed Add DeepWiki Badge in README 2025-06-05 02:09:21 +08:00
Apple\Apple 9b3c48c7ce 🎨 style: update ModelPricing card with IconLayers and larger icon size
Replace the main card icon in ModelPricing component with IconLayers to better represent the model catalog functionality. Increase icon size from 'large' to 'extra-large' for improved visibility and visual hierarchy.

This change enhances the user interface by using more appropriate iconography and giving the primary navigation elements greater emphasis.
2025-06-04 21:39:04 +08:00
Apple\Apple 085bb658e9 feat: enhance model display with category counts and consistent styling
This commit improves the model display interface with several enhancements:

1. Add model count badges to each category tab and dropdown menu item
2. Highlight active category with red badge and use grey for inactive ones
3. Optimize performance by caching category counts with useMemo
4. Standardize model tag rendering across components:
   - Replace direct Tag component with centralized renderModelTag function
   - Update renderModelTag to use stringToColor for consistent coloring
   - Remove redundant color calculations in LogsTable

These changes improve the UI by providing users with visual cues about model distribution across categories while ensuring consistent styling throughout the application.
2025-06-04 21:33:24 +08:00
Calcium-Ion 7111ec2e10 Merge pull request #1148 from RedwindA/x-goog-api-key
feat: support X-goog-api-key;remove [Done] in stream mode
2025-06-04 20:47:31 +08:00
Apple\Apple 5a7314eea0 🎨 refactor: standardize model tag rendering across components
Refactor model tag rendering to ensure consistency throughout the application:

- Replace direct Tag component in ModelPricing with centralized renderModelTag function
- Update renderModelTag in render.js to use stringToColor for consistent color generation
- Remove redundant stringToColor calls in LogsTable.js renderModelName function

This change improves UI consistency by ensuring all model tags have the same styling, iconography, and color generation logic. Model tags now automatically display appropriate vendor icons based on the model name pattern.
2025-06-04 20:13:02 +08:00
RedwindA 0827ebd22e fix: 移除流式响应结尾的[Done],以适应Gemini API的行为 2025-06-04 15:41:25 +08:00
RedwindA 5e88e76001 feat: 支持从 x-goog-api-key header 中获取授权密钥 2025-06-04 15:41:25 +08:00
Apple\Apple 7c0c6b848c 🐛 fix(channels): resolve type mismatch when copying tested channels
Fix JSON unmarshal error that occurred when copying channels after testing. The JavaScript timestamp (floating point number) in the test_time field was causing type conversion errors in Go backend which expected an int64.

Solution:
- Create deep copy of channel record instead of modifying original
- Remove test_time and response_time fields before sending to backend
- Allow backend to use default values for these fields

Error fixed: "json: cannot unmarshal number into Go struct field Channel.test_time of type int64"
2025-06-04 15:29:10 +08:00
Apple\Apple 04007b5955 🐛 fix(sidebar): fix the sidebar user permission to display navigation 2025-06-04 14:41:18 +08:00
Apple\Apple f2bca12d9a feat(ui): Enhance model tag rendering in logs table with icons
Improve the logs table by implementing brand-specific model icons and better
redirection visualization:

- Replace standard tags with `renderModelTag` to display appropriate brand
  icons for each model (OpenAI, Claude, Gemini, etc.)
- Fix background colors by explicitly passing color parameters
- Restore descriptive text for model redirection in popover
- Replace refresh icon with forward icon for better representation of model
  redirection
- Clean up unused imports

This change provides a more intuitive visual representation of models and
their relationships, making the logs table easier to understand at a glance.
2025-06-04 14:31:54 +08:00
Apple\Apple 56491f4df6 💅 feat(ui): Remove fixed width constraints from model pricing table
This commit removes all fixed width constraints from the model pricing table columns, allowing them to naturally expand and adjust based on content. This improves the table's responsiveness and ensures better space utilization across different screen sizes.
2025-06-04 13:16:45 +08:00
Apple\Apple 3feed08651 feat(ui): add the attribute mode="password" to the password input on the Setup page to enable password visibility toggle 2025-06-04 08:50:38 +08:00
Apple\Apple d5a3b05ba6 🔖chore(text): Optimize the text prompts on the login and registration pages 2025-06-04 08:46:21 +08:00
Apple\Apple 80d43b0082 Refactor: Conditionally render OAuth options in login and registration forms
This commit refactors the login and registration forms to enhance user experience by conditionally displaying OAuth-related UI elements.

- In `LoginForm.js` and `RegisterForm.js`:
    - The "Other login/registration options" button and the "or" divider are now only displayed if at least one OAuth provider is enabled in the system settings.
    - This change ensures a cleaner interface when no OAuth options are configured, preventing user confusion.
- In `RegisterForm.js`:
    - Changed `div` class from `relative` to `min-h-screen relative` to ensure the registration form an take up the entire screen height.
2025-06-04 08:34:52 +08:00
Apple\Apple 618ed976f1 🎨 refactor(ui): modernize setup page
- Refactored system initialization page using TailwindCSS 3 and SemiUI components
- Changed layout from step navigation to single page display for all configurations
- Modified top area from vertical to more compact horizontal layout
- Updated gradient color scheme from blue/purple to orange/pink
- Fixed form field name duplication issues and optimized Form implementation
- Changed usage mode selection from three-column grid to vertical layout
- Replaced usage mode card icons from settings to more appropriate Layers icon
- Added specific prompts for different database types (SQLite/MySQL/PostgreSQL)
- Removed configuration summary section while keeping the initialization button
- Fixed useSetupCheck issue by using SetupCheck as a direct component for proper redirection
2025-06-04 08:15:48 +08:00
Apple\Apple 3b1d1de912 Merge branch 'ui/refactor' into alpha 2025-06-04 01:43:08 +08:00
Apple\Apple f44e7258b9 Merge remote-tracking branch 'origin/alpha' into ui/refactor 2025-06-04 01:35:51 +08:00
Apple\Apple 0ea194e7d7 Merge branch 'main' into ui/refactor 2025-06-04 01:31:41 +08:00
CaIon 21613f8f73 Merge remote-tracking branch 'origin/main' into alpha 2025-06-04 01:25:09 +08:00
CaIon eaaee594ef chore: add Docker Buildx setup to workflow
- Integrated Docker Buildx setup step in docker-image-alpha.yml to enhance multi-platform build capabilities.
2025-06-04 01:16:42 +08:00
Apple\Apple 6a8971dd98 💄 style: Remove min-h-screen style 2025-06-04 01:03:32 +08:00
Apple\Apple 912c11ab2c ♻️ refactor: refactor the logic of fetchTokenKeys and SetupCheck 2025-06-04 01:00:48 +08:00
Apple\Apple 60e6fc67aa ♻️ refactor(components): refactor the components folder structure and related imports 2025-06-04 00:42:06 +08:00
Apple\Apple 0cd5e5cf48 ♻️ refactor(auth): move the auth component to the auth.js file in the helpers folder 2025-06-04 00:21:03 +08:00
Apple\Apple 1c0c24e0ac ♻️ refactor(components): refactor the components/utils.js to helpers/api.js and related imports 2025-06-04 00:14:15 +08:00
Apple\Apple 3daa5f70bd ♻️ refactor(utils.js): refactor the components/utils.js to helpers/api.js and related imports 2025-06-04 00:11:52 +08:00
Apple\Apple d98fb70328 ♻️ refactor(helpers): refactor the components/utils.js to helpers/api.js and related imports 2025-06-04 00:11:06 +08:00
Apple\Apple 4794a7cadc 🐛 fix(api): resolve missing imports in buildApiPayload function
Fix ReferenceError caused by undefined `isValidMessage` and `MESSAGE_ROLES`
in the buildApiPayload function within api.js.

Changes:
- Add missing `isValidMessage` import from utils.js
- Add missing `MESSAGE_ROLES` import from playground constants
- Consolidate duplicate `formatMessageForAPI` import
- Clean up import statements organization

Resolves: ReferenceError: isValidMessage is not defined at buildApiPayload (api.js:39:13)
2025-06-04 00:01:41 +08:00
Apple\Apple 61d1add156 ♻️ refactor(helpers): refactor the helpers folder and related imports 2025-06-03 23:56:39 +08:00
Apple\Apple 1a8888211f ♻️ refactor(helpers): standardize file naming conventions and improve code organization
- Rename files to follow camelCase naming convention:
  • auth-header.js → authUtils.js
  • other.js → logUtils.js
  • rehypeSplitWordsIntoSpans.js → textAnimationUtils.js

- Update import paths in affected components:
  • Update exports in helpers/index.js
  • Fix import in LogsTable.js for logUtils
  • Fix import in MarkdownRenderer.js for textAnimationUtils

- Remove old files after successful migration

- Improve file naming clarity:
  • authUtils.js better describes authentication utilities
  • logUtils.js clearly indicates log processing functions
  • textAnimationUtils.js concisely describes text animation functionality

This refactoring enhances code maintainability and follows consistent naming patterns throughout the helpers directory.
2025-06-03 16:13:50 +08:00
IcedTangerine b90aa227ef Merge pull request #1107 from QuantumNous/gemini-relay
Gemini 格式
2025-06-03 10:50:50 +08:00
creamlike1024 77d6299557 vertex 2025-05-26 15:02:20 +08:00
creamlike1024 253f487d80 gemini stream 2025-05-26 14:50:50 +08:00
creamlike1024 75d859dce2 gemini text generation 2025-05-26 13:34:41 +08:00
107 changed files with 4171 additions and 2868 deletions
+3
View File
@@ -38,6 +38,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
+3
View File
@@ -44,6 +44,9 @@
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
You can also access the AI-generated DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Key Features
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
+3
View File
@@ -44,6 +44,9 @@
详细文档请访问我们的官方Wiki[https://docs.newapi.pro/](https://docs.newapi.pro/)
也可访问AI生成的DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ 主要特性
New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction)
+2
View File
@@ -40,6 +40,8 @@ func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
err = relay.EmbeddingHelper(c)
case relayconstant.RelayModeResponses:
err = relay.ResponsesHelper(c)
case relayconstant.RelayModeGemini:
err = relay.GeminiHelper(c)
default:
err = relay.TextHelper(c)
}
+12 -11
View File
@@ -7,17 +7,18 @@ type ClaudeMetadata struct {
}
type ClaudeMediaMessage struct {
Type string `json:"type,omitempty"`
Text *string `json:"text,omitempty"`
Model string `json:"model,omitempty"`
Source *ClaudeMessageSource `json:"source,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
Type string `json:"type,omitempty"`
Text *string `json:"text,omitempty"`
Model string `json:"model,omitempty"`
Source *ClaudeMessageSource `json:"source,omitempty"`
Usage *ClaudeUsage `json:"usage,omitempty"`
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// tool_calls
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
+4 -1
View File
@@ -29,7 +29,6 @@ type GeneralOpenAIRequest struct {
MaxTokens uint `json:"max_tokens,omitempty"`
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
//Reasoning json.RawMessage `json:"reasoning,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
TopK int `json:"top_k,omitempty"`
@@ -56,6 +55,8 @@ type GeneralOpenAIRequest struct {
EnableThinking any `json:"enable_thinking,omitempty"` // ali
ExtraBody any `json:"extra_body,omitempty"`
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Reasoning json.RawMessage `json:"reasoning,omitempty"`
}
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
@@ -125,6 +126,8 @@ type MediaContent struct {
InputAudio any `json:"input_audio,omitempty"`
File any `json:"file,omitempty"`
VideoUrl any `json:"video_url,omitempty"`
// OpenRouter Params
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
func (m *MediaContent) GetImageMedia() *MessageImageUrl {
+15 -2
View File
@@ -1,13 +1,14 @@
package middleware
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
func validUserInfo(username string, role int) bool {
@@ -182,6 +183,18 @@ func TokenAuth() func(c *gin.Context) {
c.Request.Header.Set("Authorization", "Bearer "+key)
}
}
// gemini api 从query中获取key
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
skKey := c.Query("key")
if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+skKey)
}
// 从x-goog-api-key header中获取key
xGoogKey := c.Request.Header.Get("x-goog-api-key")
if xGoogKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+xGoogKey)
}
}
key := c.Request.Header.Get("Authorization")
parts := make([]string, 0)
key = strings.TrimPrefix(key, "Bearer ")
+36
View File
@@ -162,6 +162,14 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
}
c.Set("platform", string(constant.TaskPlatformSuno))
c.Set("relay_mode", relayMode)
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
relayMode := relayconstant.RelayModeGemini
modelName := extractModelNameFromGeminiPath(c.Request.URL.Path)
if modelName != "" {
modelRequest.Model = modelName
}
c.Set("relay_mode", relayMode)
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
err = common.UnmarshalBodyReusable(c, &modelRequest)
}
@@ -244,3 +252,31 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
c.Set("bot_id", channel.Other)
}
}
// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名
// 输入格式: /v1beta/models/gemini-2.0-flash:generateContent
// 输出: gemini-2.0-flash
func extractModelNameFromGeminiPath(path string) string {
// 查找 "/models/" 的位置
modelsPrefix := "/models/"
modelsIndex := strings.Index(path, modelsPrefix)
if modelsIndex == -1 {
return ""
}
// 从 "/models/" 之后开始提取
startIndex := modelsIndex + len(modelsPrefix)
if startIndex >= len(path) {
return ""
}
// 查找 ":" 的位置,模型名在 ":" 之前
colonIndex := strings.Index(path[startIndex:], ":")
if colonIndex == -1 {
// 如果没有找到 ":",返回从 "/models/" 到路径结尾的部分
return path[startIndex:]
}
// 返回模型名部分
return path[startIndex : startIndex+colonIndex]
}
+9
View File
@@ -10,6 +10,7 @@ import (
"one-api/dto"
"one-api/relay/channel"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/service"
"one-api/setting/model_setting"
"strings"
@@ -165,6 +166,14 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
if info.RelayMode == constant.RelayModeGemini {
if info.IsStream {
return GeminiTextGenerationStreamHandler(c, resp, info)
} else {
return GeminiTextGenerationHandler(c, resp, info)
}
}
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
return GeminiImageHandler(c, resp, info)
}
+12 -11
View File
@@ -27,14 +27,9 @@ type FunctionCall struct {
Arguments any `json:"args"`
}
type GeminiFunctionResponseContent struct {
Name string `json:"name"`
Content any `json:"content"`
}
type FunctionResponse struct {
Name string `json:"name"`
Response GeminiFunctionResponseContent `json:"response"`
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
type GeminiPartExecutableCode struct {
@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
// Imagen related structs
+145
View File
@@ -0,0 +1,145 @@
package gemini
import (
"encoding/json"
"io"
"net/http"
"one-api/common"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"github.com/gin-gonic/gin"
)
func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) {
// 读取响应体
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
}
err = resp.Body.Close()
if err != nil {
return nil, service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
}
if common.DebugEnabled {
println(string(responseBody))
}
// 解析为 Gemini 原生响应格式
var geminiResponse GeminiChatResponse
err = common.DecodeJson(responseBody, &geminiResponse)
if err != nil {
return nil, service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
}
// 检查是否有候选响应
if len(geminiResponse.Candidates) == 0 {
return nil, &dto.OpenAIErrorWithStatusCode{
Error: dto.OpenAIError{
Message: "No candidates returned",
Type: "server_error",
Param: "",
Code: 500,
},
StatusCode: resp.StatusCode,
}
}
// 计算使用量(基于 UsageMetadata
usage := dto.Usage{
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
// 直接返回 Gemini 原生格式的 JSON 响应
jsonResponse, err := json.Marshal(geminiResponse)
if err != nil {
return nil, service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError)
}
// 设置响应头并写入响应
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
if err != nil {
return nil, service.OpenAIErrorWrapper(err, "write_response_failed", http.StatusInternalServerError)
}
return &usage, nil
}
func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) {
var usage = &dto.Usage{}
var imageCount int
helper.SetEventStreamHeaders(c)
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
var geminiResponse GeminiChatResponse
err := common.DecodeJsonStr(data, &geminiResponse)
if err != nil {
common.LogError(c, "error unmarshalling stream response: "+err.Error())
return false
}
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
if part.InlineData != nil && part.InlineData.MimeType != "" {
imageCount++
}
}
}
// 更新使用量统计
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
}
// 直接发送 GeminiChatResponse 响应
err = helper.ObjectData(c, geminiResponse)
if err != nil {
common.LogError(c, err.Error())
}
return true
})
if imageCount != 0 {
if usage.CompletionTokens == 0 {
usage.CompletionTokens = imageCount * 258
}
}
// 计算最终使用量
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
//helper.Done(c)
return usage, nil
}
+77 -29
View File
@@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget
if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true,
}
}
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 硬编码不支持 ThinkingBudget 的旧模型
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
isUnsupported = true
break
}
}
if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if isNew25Pro {
// 新的2.5pro模型:ThinkingBudget范围为128-32768
if budgetTokens == 0 || budgetTokens < 128 {
budgetTokens = 128
} else if budgetTokens > 32768 {
budgetTokens = 32768
}
} else {
// 其他模型:ThinkingBudget范围为0-24576
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true,
}
}
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if !isNew25Pro {
// 只有非新2.5pro模型才支持-nothinking
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
}
}
}
@@ -173,17 +211,12 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
name = val
}
content := common.StrToMap(message.StringContent())
contentMap := common.StrToMap(message.StringContent())
functionResp := &FunctionResponse{
Name: name,
Response: GeminiFunctionResponseContent{
Name: name,
Content: content,
},
}
if content == nil {
functionResp.Response.Content = message.StringContent()
Name: name,
Response: contentMap,
}
*parts = append(*parts, GeminiPart{
FunctionResponse: functionResp,
})
@@ -280,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data)
base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: format,
MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String,
},
})
@@ -738,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
}
err = helper.ObjectData(c, response)
if err != nil {
@@ -812,6 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
fullTextResponse.Usage = usage
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
+9
View File
@@ -0,0 +1,9 @@
package openrouter
type RequestReasoning struct {
// One of the following (not both):
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style)
// Optional: Default is false. All models support this.
Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response
}
+11 -2
View File
@@ -12,6 +12,7 @@ import (
"one-api/relay/channel/gemini"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/setting/model_setting"
"strings"
@@ -201,7 +202,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
case RequestModeClaude:
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
case RequestModeGemini:
err, usage = gemini.GeminiChatStreamHandler(c, resp, info)
if info.RelayMode == constant.RelayModeGemini {
usage, err = gemini.GeminiTextGenerationStreamHandler(c, resp, info)
} else {
err, usage = gemini.GeminiChatStreamHandler(c, resp, info)
}
case RequestModeLlama:
err, usage = openai.OaiStreamHandler(c, resp, info)
}
@@ -210,7 +215,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
case RequestModeClaude:
err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info)
case RequestModeGemini:
err, usage = gemini.GeminiChatHandler(c, resp, info)
if info.RelayMode == constant.RelayModeGemini {
usage, err = gemini.GeminiTextGenerationHandler(c, resp, info)
} else {
err, usage = gemini.GeminiChatHandler(c, resp, info)
}
case RequestModeLlama:
err, usage = openai.OpenaiHandler(c, resp, info)
}
+4
View File
@@ -43,6 +43,8 @@ const (
RelayModeResponses
RelayModeRealtime
RelayModeGemini
)
func Path2RelayMode(path string) int {
@@ -75,6 +77,8 @@ func Path2RelayMode(path string) int {
relayMode = RelayModeRerank
} else if strings.HasPrefix(path, "/v1/realtime") {
relayMode = RelayModeRealtime
} else if strings.HasPrefix(path, "/v1beta/models") {
relayMode = RelayModeGemini
}
return relayMode
}
+157
View File
@@ -0,0 +1,157 @@
package relay
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/relay/channel/gemini"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
"one-api/setting"
"strings"
"github.com/gin-gonic/gin"
)
func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) {
request := &gemini.GeminiChatRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
return nil, err
}
if len(request.Contents) == 0 {
return nil, errors.New("contents is required")
}
return request, nil
}
// 流模式
// /v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=xxx
func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
if c.Query("alt") == "sse" {
relayInfo.IsStream = true
}
// if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
// relayInfo.IsStream = true
// }
}
func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) {
var inputTexts []string
for _, content := range textRequest.Contents {
for _, part := range content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
}
if len(inputTexts) == 0 {
return nil, nil
}
sensitiveWords, err := service.CheckSensitiveInput(inputTexts)
return sensitiveWords, err
}
func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) (int, error) {
// 计算输入 token 数量
var inputTexts []string
for _, content := range req.Contents {
for _, part := range content.Parts {
if part.Text != "" {
inputTexts = append(inputTexts, part.Text)
}
}
}
inputText := strings.Join(inputTexts, "\n")
inputTokens, err := service.CountTokenInput(inputText, info.UpstreamModelName)
info.PromptTokens = inputTokens
return inputTokens, err
}
func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
req, err := getAndValidateGeminiRequest(c)
if err != nil {
common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error()))
return service.OpenAIErrorWrapperLocal(err, "invalid_gemini_request", http.StatusBadRequest)
}
relayInfo := relaycommon.GenRelayInfo(c)
// 检查 Gemini 流式模式
checkGeminiStreamMode(c, relayInfo)
if setting.ShouldCheckPromptSensitive() {
sensitiveWords, err := checkGeminiInputSensitive(req)
if err != nil {
common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", ")))
return service.OpenAIErrorWrapperLocal(err, "check_request_sensitive_error", http.StatusBadRequest)
}
}
// model mapped 模型映射
err = helper.ModelMappedHelper(c, relayInfo)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest)
}
if value, exists := c.Get("prompt_tokens"); exists {
promptTokens := value.(int)
relayInfo.SetPromptTokens(promptTokens)
} else {
promptTokens, err := getGeminiInputTokens(req, relayInfo)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
}
c.Set("prompt_tokens", promptTokens)
}
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens))
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
}
// pre consume quota
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
if openaiErr != nil {
return openaiErr
}
defer func() {
if openaiErr != nil {
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
}
}()
adaptor := GetAdaptor(relayInfo.ApiType)
if adaptor == nil {
return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest)
}
adaptor.Init(relayInfo)
requestBody, err := json.Marshal(req)
if err != nil {
return service.OpenAIErrorWrapperLocal(err, "marshal_text_request_failed", http.StatusInternalServerError)
}
resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody))
if err != nil {
common.LogError(c, "Do gemini request failed: "+err.Error())
return service.OpenAIErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError)
}
usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo)
if openaiErr != nil {
return openaiErr
}
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
return nil
}
+38 -9
View File
@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
promptTokens := usage.PromptTokens
cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens
modelName := relayInfo.OriginModelName
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
fileSearchTool.CallCount, dFileSearchQuota.String())
}
}
var quotaCalculateDecimal decimal.Decimal
if !priceData.UsePrice {
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
if imageTokens > 0 {
nonImageTokens := dPromptTokens.Sub(dImageTokens)
imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
promptQuota = nonImageTokens.Add(imageTokensWithRatio)
var audioInputQuota decimal.Decimal
var audioInputPrice float64
if !priceData.UsePrice {
baseTokens := dPromptTokens
// 减去 cached tokens
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
baseTokens = baseTokens.Sub(dCacheTokens)
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
}
// 减去 Gemini audio tokens
if !dAudioTokens.IsZero() {
audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
if audioInputPrice > 0 {
// 重新计算 base tokens
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other["file_search_price"] = fileSearchPrice
}
}
if !audioInputQuota.IsZero() {
other["audio_input_seperate_price"] = true
other["audio_input_token_count"] = audioTokens
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)
}
+8
View File
@@ -79,6 +79,14 @@ func SetRelayRouter(router *gin.Engine) {
relaySunoRouter.GET("/fetch/:id", controller.RelayTask)
}
relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.TokenAuth())
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
relayGeminiRouter.Use(middleware.Distribute())
{
// Gemini API 路径格式: /v1beta/models/{model_name}:{action}
relayGeminiRouter.POST("/models/*path", controller.Relay)
}
}
func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
+17
View File
@@ -3,6 +3,7 @@ package service
import (
"encoding/base64"
"fmt"
"strings"
)
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
@@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error)
duration = float64(samplesCount) / float64(sampleRate)
return duration, nil
}
func DecodeBase64AudioData(audioBase64 string) (string, error) {
// 检查并移除 data:audio/xxx;base64, 前缀
idx := strings.Index(audioBase64, ",")
if idx != -1 {
audioBase64 = audioBase64[idx+1:]
}
// 解码 Base64 数据
_, err := base64.StdEncoding.DecodeString(audioBase64)
if err != nil {
return "", fmt.Errorf("base64 decode error: %v", err)
}
return audioBase64, nil
}
+40 -10
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"one-api/common"
"one-api/dto"
"one-api/relay/channel/openrouter"
relaycommon "one-api/relay/common"
"strings"
)
@@ -18,10 +19,24 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
Stream: claudeRequest.Stream,
}
isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter
if claudeRequest.Thinking != nil {
if strings.HasSuffix(info.OriginModelName, "-thinking") &&
!strings.HasSuffix(claudeRequest.Model, "-thinking") {
openAIRequest.Model = openAIRequest.Model + "-thinking"
if isOpenRouter {
reasoning := openrouter.RequestReasoning{
MaxTokens: claudeRequest.Thinking.BudgetTokens,
}
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
} else {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
}
@@ -62,16 +77,30 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
} else {
systems := claudeRequest.ParseSystem()
if len(systems) > 0 {
systemStr := ""
openAIMessage := dto.Message{
Role: "system",
}
for _, system := range systems {
if system.Text != nil {
systemStr += *system.Text
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude")
if isOpenRouterClaude {
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
for _, system := range systems {
message := dto.MediaContent{
Type: "text",
Text: system.GetText(),
CacheControl: system.CacheControl,
}
systemMediaMessages = append(systemMediaMessages, message)
}
openAIMessage.SetMediaContent(systemMediaMessages)
} else {
systemStr := ""
for _, system := range systems {
if system.Text != nil {
systemStr += *system.Text
}
}
openAIMessage.SetStringContent(systemStr)
}
openAIMessage.SetStringContent(systemStr)
openAIMessages = append(openAIMessages, openAIMessage)
}
}
@@ -97,8 +126,9 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
switch mediaMsg.Type {
case "text":
message := dto.MediaContent{
Type: "text",
Text: mediaMsg.GetText(),
Type: "text",
Text: mediaMsg.GetText(),
CacheControl: mediaMsg.CacheControl,
}
mediaMessages = append(mediaMessages, message)
case "image":
+18
View File
@@ -14,6 +14,13 @@ const (
FileSearchPrice = 2.5
)
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
)
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
// 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
func GetFileSearchPricePerThousand() float64 {
return FileSearchPrice
}
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
return Gemini25FlashNativeAudioInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
}
return 0
}

Before

Width:  |  Height:  |  Size: 550 KiB

After

Width:  |  Height:  |  Size: 550 KiB

+10 -12
View File
@@ -1,15 +1,15 @@
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import React, { lazy, Suspense } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/Loading';
import Loading from './components/common/Loading.js';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import { AuthRedirect, PrivateRoute } from './helpers';
import RegisterForm from './components/auth/RegisterForm.js';
import LoginForm from './components/auth/LoginForm.js';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import PasswordResetForm from './components/PasswordResetForm';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import PasswordResetForm from './components/auth/PasswordResetForm.js';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
import Channel from './pages/Channel';
import Token from './pages/Token';
import EditChannel from './pages/Channel/EditChannel';
@@ -18,16 +18,14 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log';
import Chat from './pages/Chat';
import Chat2Link from './pages/Chat2Link';
import { Layout } from '@douyinfe/semi-ui';
import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js';
import Task from './pages/Task/index.js';
import Playground from './pages/Playground/index.js';
import OAuth2Callback from './components/OAuth2Callback.js';
import PersonalSetting from './components/PersonalSetting.js';
import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/settings/PersonalSetting.js';
import Setup from './pages/Setup/index.js';
import SetupCheck from './components/SetupCheck';
import AuthRedirect from './components/AuthRedirect';
import SetupCheck from './components/layout/SetupCheck.js';
const Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail'));
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
const AuthRedirect = ({ children }) => {
const user = localStorage.getItem('user');
if (user) {
return <Navigate to="/console" replace />;
}
return children;
};
export default AuthRedirect;
-94
View File
@@ -1,94 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../helpers';
import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
const NoticeModal = ({ visible, onClose, isMobile }) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
const handleCloseTodayNotice = () => {
const today = new Date().toDateString();
localStorage.setItem('notice_close_date', today);
onClose();
};
const displayNotice = async () => {
setLoading(true);
try {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
if (data !== '') {
const htmlNotice = marked.parse(data);
setNoticeContent(htmlNotice);
} else {
setNoticeContent('');
}
} else {
showError(message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
displayNotice();
}
}, [visible]);
const renderContent = () => {
if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
}
if (!noticeContent) {
return (
<div className="py-12">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('暂无公告')}
/>
</div>
);
}
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
/>
);
};
return (
<Modal
title={t('系统公告')}
visible={visible}
onCancel={onClose}
footer={(
<div className="flex justify-end">
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
</div>
)}
size={isMobile ? 'full-width' : 'large'}
>
{renderContent()}
</Modal>
);
};
export default NoticeModal;
-12
View File
@@ -1,12 +0,0 @@
import { Navigate } from 'react-router-dom';
import { history } from '../helpers';
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };
-519
View File
@@ -1,519 +0,0 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../context/User';
import { StatusContext } from '../context/Status';
import { useTranslation } from 'react-i18next';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showError,
} from '../helpers';
import '../index.css';
import {
IconCalendarClock,
IconChecklistStroked,
IconComment,
IconTerminal,
IconCreditCard,
IconGift,
IconHelpCircle,
IconHistogram,
IconHome,
IconImage,
IconKey,
IconLayers,
IconPriceTag,
IconSetting,
IconUser,
} from '@douyinfe/semi-icons';
import {
Avatar,
Dropdown,
Layout,
Nav,
Switch,
Divider,
} from '@douyinfe/semi-ui';
import { setStatusData } from '../helpers/data.js';
import { stringToColor } from '../helpers/render.js';
import { useSetTheme, useTheme } from '../context/Theme/index.js';
import { useStyle, styleActions } from '../context/Style/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
// 自定义侧边栏按钮样式
const navItemStyle = {
borderRadius: '6px',
margin: '4px 8px',
};
// 自定义侧边栏按钮悬停样式
const navItemHoverStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
};
// 自定义侧边栏按钮选中样式
const navItemSelectedStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
fontWeight: '600',
};
// 自定义图标样式
const iconStyle = (itemKey, selectedKeys) => {
return {
fontSize: '18px',
color: selectedKeys.includes(itemKey)
? 'var(--semi-color-primary)'
: 'var(--semi-color-text-2)',
};
};
// Define routerMap as a constant outside the component
const routerMap = {
home: '/',
channel: '/console/channel',
token: '/console/token',
redemption: '/console/redemption',
topup: '/console/topup',
user: '/console/user',
log: '/console/log',
midjourney: '/console/midjourney',
setting: '/console/setting',
about: '/about',
detail: '/console',
pricing: '/pricing',
task: '/console/task',
playground: '/console/playground',
personal: '/console/personal',
};
const SiderBar = () => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [statusState, statusDispatch] = useContext(StatusContext);
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const theme = useTheme();
const setTheme = useSetTheme();
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
// 预先计算所有可能的图标样式
const allItemKeys = useMemo(() => {
const keys = [
'home',
'channel',
'token',
'redemption',
'topup',
'user',
'log',
'midjourney',
'setting',
'about',
'chat',
'detail',
'pricing',
'task',
'playground',
'personal',
];
// 添加聊天项的keys
for (let i = 0; i < chatItems.length; i++) {
keys.push('chat' + i);
}
return keys;
}, [chatItems]);
// 使用useMemo一次性计算所有图标样式
const iconStyles = useMemo(() => {
const styles = {};
allItemKeys.forEach((key) => {
styles[key] = iconStyle(key, selectedKeys);
});
return styles;
}, [allItemKeys, selectedKeys]);
const workspaceItems = useMemo(
() => [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
icon: <IconCalendarClock />,
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('API令牌'),
itemKey: 'token',
to: '/token',
icon: <IconKey />,
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
icon: <IconHistogram />,
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
icon: <IconImage />,
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
icon: <IconChecklistStroked />,
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
],
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
],
);
const financeItems = useMemo(
() => [
{
text: t('钱包'),
itemKey: 'topup',
to: '/topup',
icon: <IconCreditCard />,
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
icon: <IconUser />,
},
],
[t],
);
const adminItems = useMemo(
() => [
{
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
icon: <IconLayers />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
icon: <IconGift />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
icon: <IconUser />,
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
icon: <IconSetting />,
},
],
[isAdmin(), t],
);
const chatMenuItems = useMemo(
() => [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
icon: <IconTerminal />,
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
icon: <IconComment />,
},
],
[chatItems, t],
);
// Function to update router map with chat routes
const updateRouterMapWithChats = (chats) => {
const newRouterMap = { ...routerMap };
if (Array.isArray(chats) && chats.length > 0) {
for (let i = 0; i < chats.length; i++) {
newRouterMap['chat' + i] = '/console/chat/' + i;
}
}
setRouterMapState(newRouterMap);
return newRouterMap;
};
// Update the useEffect for chat items
useEffect(() => {
let chats = localStorage.getItem('chats');
if (chats) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
let chatItems = [];
for (let i = 0; i < chats.length; i++) {
let chat = {};
for (let key in chats[i]) {
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/console/chat/' + i;
}
chatItems.push(chat);
}
setChatItems(chatItems);
// Update router map with chat routes
updateRouterMapWithChats(chats);
}
} catch (e) {
console.error(e);
showError('聊天数据解析失败');
}
}
}, []);
// Update the useEffect for route selection
useEffect(() => {
const currentPath = location.pathname;
let matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
// Handle chat routes
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
const chatIndex = currentPath.split('/').pop();
if (!isNaN(chatIndex)) {
matchingKey = 'chat' + chatIndex;
} else {
matchingKey = 'chat';
}
}
// If we found a matching key, update the selected keys
if (matchingKey) {
setSelectedKeys([matchingKey]);
}
}, [location.pathname, routerMapState]);
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
// Custom divider style
const dividerStyle = {
margin: '8px 0',
opacity: 0.6,
};
// Custom group label style
const groupLabelStyle = {
padding: '8px 16px',
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.5px',
};
return (
<>
<Nav
className='custom-sidebar-nav'
style={{
width: isCollapsed ? '60px' : '200px',
borderRight: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-0)',
borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
position: 'relative',
zIndex: 95,
height: '100%',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
}}
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目,避免不必要的计算
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle={navItemStyle}
hoverStyle={navItemHoverStyle}
selectedStyle={navItemSelectedStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
return (
<Link
style={{ textDecoration: 'none' }}
to={routerMapState[props.itemKey] || routerMap[props.itemKey]}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* Chat Section - Only show if there are chat items */}
{chatMenuItems.map((item) => {
if (item.items && item.items.length > 0) {
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
>
{item.items.map((subItem) => (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={subItem.text}
/>
))}
</Nav.Sub>
);
} else {
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
/>
);
}
})}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Workspace Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
{workspaceItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
{isAdmin() && (
<>
{/* Divider */}
<Divider style={dividerStyle} />
{/* Admin Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
{adminItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
</>
)}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Finance Management Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
{financeItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
<Nav.Footer
collapseButton={true}
collapseText={(collapsed) => {
if (collapsed) {
return t('展开侧边栏');
}
return t('收起侧边栏');
}}
/>
</Nav>
</>
);
};
export default SiderBar;
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import { UserContext } from '../../context/User/index.js';
import {
API,
getLogo,
@@ -9,12 +9,11 @@ import {
showSuccess,
updateAPI,
getSystemName,
} from '../helpers';
import {
setUserData,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
} from './utils';
onLinuxDOOAuthClicked
} from '../../helpers/index.js';
import Turnstile from 'react-turnstile';
import {
Button,
@@ -29,12 +28,11 @@ import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
import OIDCIcon from './common/logo/OIDCIcon.js';
import WeChatIcon from './common/logo/WeChatIcon.js';
import { setUserData } from '../helpers/data.js';
import LinuxDoIcon from './common/logo/LinuxDoIcon.js';
import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
import Background from '../images/example.png';
import Background from '/example.png';
const LoginForm = () => {
const [inputs, setInputs] = useState({
@@ -296,7 +294,7 @@ const LoginForm = () => {
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" style={{ color: '#24292e' }} />}
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
@@ -355,7 +353,7 @@ const LoginForm = () => {
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className="ml-3">{t('使用 邮箱 登录')}</span>
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
@@ -364,17 +362,6 @@ const LoginForm = () => {
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
@@ -397,8 +384,8 @@ const LoginForm = () => {
<Form className="space-y-3">
<Form.Input
field="username"
label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')}
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name="username"
size="large"
className="!rounded-md"
@@ -444,21 +431,29 @@ const LoginForm = () => {
</div>
</Form>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
{t('其他登录选项')}
</Button>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
{t('其他登录选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
</div>
</Card>
@@ -522,6 +517,17 @@ const LoginForm = () => {
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
@@ -1,9 +1,8 @@
import React, { useContext, useEffect, useState } from 'react';
import { Spin, Typography, Space } from '@douyinfe/semi-ui';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess, updateAPI } from '../helpers';
import { UserContext } from '../context/User';
import { setUserData } from '../helpers/data.js';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { UserContext } from '../../context/User';
const OAuth2Callback = (props) => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../helpers';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail, IconLock } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import Background from '../images/example.png';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react';
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../helpers';
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Background from '../images/example.png';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -8,7 +8,8 @@ import {
showSuccess,
updateAPI,
getSystemName,
} from '../helpers';
setUserData
} from '../../helpers/index.js';
import Turnstile from 'react-turnstile';
import {
Button,
@@ -25,15 +26,14 @@ import {
onGitHubOAuthClicked,
onLinuxDOOAuthClicked,
onOIDCClicked,
} from './utils.js';
import OIDCIcon from './common/logo/OIDCIcon.js';
import LinuxDoIcon from './common/logo/LinuxDoIcon.js';
import WeChatIcon from './common/logo/WeChatIcon.js';
} from '../../helpers/index.js';
import OIDCIcon from '../common/logo/OIDCIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { setUserData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
import Background from '../images/example.png';
import Background from '/example.png';
const RegisterForm = () => {
const { t } = useTranslation();
@@ -300,7 +300,7 @@ const RegisterForm = () => {
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" style={{ color: '#24292e' }} />}
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
@@ -359,7 +359,7 @@ const RegisterForm = () => {
onClick={handleEmailRegisterClick}
loading={emailRegisterLoading}
>
<span className="ml-3">{t('使用 邮箱 注册')}</span>
<span className="ml-3">{t('使用 用户名 注册')}</span>
</Button>
</div>
@@ -368,17 +368,6 @@ const RegisterForm = () => {
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
@@ -485,22 +474,26 @@ const RegisterForm = () => {
</div>
</Form>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading}
>
{t('其他注册选项')}
</Button>
</div>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading}
>
{t('其他注册选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
@@ -564,6 +557,17 @@ const RegisterForm = () => {
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
@@ -1,6 +1,6 @@
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/default.css';
import 'highlight.js/styles/github.css';
import './markdown.css';
import RemarkMath from 'remark-math';
import RemarkBreaks from 'remark-breaks';
@@ -13,10 +13,9 @@ import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import clsx from 'clsx';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { copy } from '../../../helpers/utils';
import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
import { IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { rehypeSplitWordsIntoSpans } from '../../../utils/rehypeSplitWordsIntoSpans';
mermaid.initialize({
startOnLoad: false,
-68
View File
@@ -1,68 +0,0 @@
// src/hooks/useTokenKeys.js
import { useEffect, useState } from 'react';
import { API, showError } from '../helpers';
async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
}
}
function getServerAddress() {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (error) {
console.error('Failed to parse status from localStorage:', error);
}
}
if (!serverAddress) {
serverAddress = window.location.origin;
}
return serverAddress;
}
export function useTokenKeys(id) {
const [keys, setKeys] = useState([]);
// const [chatLink, setChatLink] = useState('');
const [serverAddress, setServerAddress] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAllData = async () => {
const fetchedKeys = await fetchTokenKeys();
if (fetchedKeys.length === 0) {
showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
setTimeout(() => {
window.location.href = '/token';
}, 1500); // 延迟 1.5 秒后跳转
}
setKeys(fetchedKeys);
setIsLoading(false);
// setChatLink(link);
const address = getServerAddress();
setServerAddress(address);
};
loadAllData();
}, []);
return { keys, serverAddress, isLoading };
}
@@ -1,8 +1,8 @@
import React, { useEffect, useState, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Typography } from '@douyinfe/semi-ui';
import { getFooterHTML, getLogo, getSystemName } from '../helpers';
import { StatusContext } from '../context/Status';
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
import { StatusContext } from '../../context/Status';
const FooterBar = () => {
const { t } = useTranslation();
@@ -22,7 +22,7 @@ const FooterBar = () => {
const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => (
<footer className="relative bg-gray-900 dark:bg-[#1C1F23] h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
@@ -38,38 +38,38 @@ const FooterBar = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
<div className="text-left">
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('关于我们')}</p>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('功能特性')}</a>
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('文档')}</p>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">{t('API 文档')}</a>
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('相关项目')}</p>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">neko-api-key-tool</a>
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
</div>
</div>
<div className="text-left">
<p className="!text-[#d9dbe1] font-semibold mb-5">{t('基于New API的项目')}</p>
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-[#d9dbe1]">VoAPI</a> */}
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
</div>
</div>
</div>
@@ -78,15 +78,15 @@ const FooterBar = () => {
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
<div className="flex flex-wrap items-center gap-2">
<Typography.Text className="text-sm !text-[#d9dbe1]">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
</div>
{isDemoSiteMode && (
<div className="text-sm">
<span className="!text-[#d9dbe1]">{t('设计与开发由')} </span>
<span className="!text-[#01ffc3]">Douyin FE</span>
<span className="!text-[#d9dbe1]"> & </span>
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-[#01ffc3] hover:!text-[#01ffc3]">QuantumNous</a>
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<span className="!text-semi-color-primary">Douyin FE</span>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
</div>
)}
</div>
@@ -1,12 +1,12 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useSetTheme, useTheme } from '../context/Theme';
import { UserContext } from '../../context/User/index.js';
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showSuccess } from '../helpers';
import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
import fireworks from 'react-fireworks';
import { CN, GB } from 'country-flag-icons/react/3x2';
import NoticeModal from './NoticeModal';
import NoticeModal from './NoticeModal.js';
import {
IconClose,
@@ -29,9 +29,8 @@ import {
Typography,
Skeleton,
} from '@douyinfe/semi-ui';
import { stringToColor } from '../helpers/render';
import { StatusContext } from '../context/Status/index.js';
import { useStyle, styleActions } from '../context/Style/index.js';
import { StatusContext } from '../../context/Status/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
const HeaderBar = () => {
const { t, i18n } = useTranslation();
@@ -469,7 +468,7 @@ const HeaderBar = () => {
onClick={() => setNoticeVisible(true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Button
@@ -478,7 +477,7 @@ const HeaderBar = () => {
onClick={() => setTheme(theme === 'dark' ? false : true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Dropdown
@@ -507,7 +506,7 @@ const HeaderBar = () => {
aria-label={t('切换语言')}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 hover:bg-semi-color-fill-1 dark:hover:bg-semi-color-fill-2"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
</Dropdown>
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
const NoticeModal = ({ visible, onClose, isMobile }) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
const handleCloseTodayNotice = () => {
const today = new Date().toDateString();
localStorage.setItem('notice_close_date', today);
onClose();
};
const displayNotice = async () => {
setLoading(true);
try {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
if (data !== '') {
const htmlNotice = marked.parse(data);
setNoticeContent(htmlNotice);
} else {
setNoticeContent('');
}
} else {
showError(message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
displayNotice();
}
}, [visible]);
const renderContent = () => {
if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
}
if (!noticeContent) {
return (
<div className="py-12">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('暂无公告')}
/>
</div>
);
}
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
/>
);
};
return (
<Modal
title={t('系统公告')}
visible={visible}
onCancel={onClose}
footer={(
<div className="flex justify-end">
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
</div>
)}
size={isMobile ? 'full-width' : 'large'}
>
{renderContent()}
</Modal>
);
};
export default NoticeModal;
@@ -1,16 +1,15 @@
import HeaderBar from './HeaderBar.js';
import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar.js';
import App from '../App.js';
import App from '../../App.js';
import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react';
import { useStyle } from '../context/Style/index.js';
import { useStyle } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
import { setStatusData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { StatusContext } from '../context/Status/index.js';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js';
import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout;
@@ -89,7 +88,6 @@ const PageLayout = () => {
width: '100%',
top: 0,
zIndex: 100,
borderBottom: '1px solid var(--semi-color-border)',
}}
>
<HeaderBar />
@@ -125,7 +123,7 @@ const PageLayout = () => {
: styleState.showSider
? styleState.siderCollapsed
? '60px'
: '200px'
: '180px'
: '0',
transition: 'margin-left 0.3s ease',
flex: '1 1 auto',
@@ -1,6 +1,6 @@
import React, { useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { StatusContext } from '../context/Status';
import { StatusContext } from '../../context/Status';
const SetupCheck = ({ children }) => {
const [statusState] = useContext(StatusContext);
+448
View File
@@ -0,0 +1,448 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react';
import { useStyle, styleActions } from '../../context/Style/index.js';
import {
isAdmin,
isRoot,
showError
} from '../../helpers/index.js';
import {
Nav,
Divider,
Tooltip,
} from '@douyinfe/semi-ui';
const routerMap = {
home: '/',
channel: '/console/channel',
token: '/console/token',
redemption: '/console/redemption',
topup: '/console/topup',
user: '/console/user',
log: '/console/log',
midjourney: '/console/midjourney',
setting: '/console/setting',
about: '/about',
detail: '/console',
pricing: '/pricing',
task: '/console/task',
playground: '/console/playground',
personal: '/console/personal',
};
const SiderBar = () => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
const workspaceItems = useMemo(
() => [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('API令牌'),
itemKey: 'token',
to: '/token',
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
],
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
],
);
const financeItems = useMemo(
() => [
{
text: t('钱包'),
itemKey: 'topup',
to: '/topup',
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
],
[t],
);
const adminItems = useMemo(
() => [
{
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
className: isRoot() ? '' : 'tableHiddle',
},
],
[isAdmin(), isRoot(), t],
);
const chatMenuItems = useMemo(
() => [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
},
],
[chatItems, t],
);
// 更新路由映射,添加聊天路由
const updateRouterMapWithChats = (chats) => {
const newRouterMap = { ...routerMap };
if (Array.isArray(chats) && chats.length > 0) {
for (let i = 0; i < chats.length; i++) {
newRouterMap['chat' + i] = '/console/chat/' + i;
}
}
setRouterMapState(newRouterMap);
return newRouterMap;
};
// 加载聊天项
useEffect(() => {
let chats = localStorage.getItem('chats');
if (chats) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
let chatItems = [];
for (let i = 0; i < chats.length; i++) {
let chat = {};
for (let key in chats[i]) {
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/console/chat/' + i;
}
chatItems.push(chat);
}
setChatItems(chatItems);
updateRouterMapWithChats(chats);
}
} catch (e) {
console.error(e);
showError('聊天数据解析失败');
}
}
}, []);
// 根据当前路径设置选中的菜单项
useEffect(() => {
const currentPath = location.pathname;
let matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
// 处理聊天路由
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
const chatIndex = currentPath.split('/').pop();
if (!isNaN(chatIndex)) {
matchingKey = 'chat' + chatIndex;
} else {
matchingKey = 'chat';
}
}
// 如果找到匹配的键,更新选中的键
if (matchingKey) {
setSelectedKeys([matchingKey]);
}
}, [location.pathname, routerMapState]);
// 同步折叠状态
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
// 获取菜单项对应的颜色
const getItemColor = (itemKey) => {
switch (itemKey) {
case 'detail': return sidebarIconColors.dashboard;
case 'playground': return sidebarIconColors.terminal;
case 'chat': return sidebarIconColors.message;
case 'token': return sidebarIconColors.key;
case 'log': return sidebarIconColors.chart;
case 'midjourney': return sidebarIconColors.image;
case 'task': return sidebarIconColors.check;
case 'topup': return sidebarIconColors.credit;
case 'channel': return sidebarIconColors.layers;
case 'redemption': return sidebarIconColors.gift;
case 'user':
case 'personal': return sidebarIconColors.user;
case 'setting': return sidebarIconColors.settings;
default:
// 处理聊天项
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
return 'currentColor';
}
};
// 渲染自定义菜单项
const renderNavItem = (item) => {
// 跳过隐藏的项目
if (item.className === 'tableHiddle') return null;
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
className={item.className}
/>
);
};
// 渲染子菜单项
const renderSubItem = (item) => {
if (item.items && item.items.length > 0) {
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
>
{item.items.map((subItem) => {
const isSubSelected = selectedKeys.includes(subItem.itemKey);
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
return (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
{subItem.text}
</span>
}
/>
);
})}
</Nav.Sub>
);
} else {
return renderNavItem(item);
}
};
return (
<div
className="sidebar-container"
style={{ width: isCollapsed ? '60px' : '180px' }}
>
<Nav
className="sidebar-nav custom-sidebar-nav"
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
selectedStyle="sidebar-nav-item-selected"
renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
// 如果没有路由,直接返回元素
if (!to) return itemElement;
return (
<Link
style={{ textDecoration: 'none' }}
to={to}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
{/* 管理员区域 - 只在管理员时显示 */}
{isAdmin() && (
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
</div>
</div>
);
};
export default SiderBar;
+1 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useMemo, useCallback } from 'react';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { copy } from '../../helpers/utils';
import { copy } from '../../helpers';
const PERFORMANCE_CONFIG = {
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
@@ -14,7 +14,7 @@ import {
Settings,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption } from '../../helpers/render.js';
import { renderGroupOption } from '../../helpers';
import ParameterControl from './ParameterControl';
import ImageUrlInput from './ImageUrlInput';
import ConfigManager from './ConfigManager';
@@ -1,4 +1,4 @@
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../utils/constants';
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
const MESSAGES_STORAGE_KEY = 'playground_messages';
@@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers';
import { API, showError, showSuccess } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
const ModelSetting = () => {
const { t } = useTranslation();
@@ -1,20 +1,20 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
import { API, showError, showSuccess } from '../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
import { API, showError, showSuccess } from '../../helpers';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
const OperationSetting = () => {
const { t } = useTranslation();
@@ -9,10 +9,10 @@ import {
Space,
Card,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess, timestamp2string } from '../helpers';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../context/Status/index.js';
import { StatusContext } from '../../context/Status/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const OtherSetting = () => {
@@ -8,24 +8,28 @@ import {
showError,
showInfo,
showSuccess,
} from '../helpers';
import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User';
import {
renderQuota,
renderQuotaWithPrompt,
stringToColor,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
} from './utils';
renderModelTag,
getModelCategories
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { UserContext } from '../../context/User';
import {
Avatar,
Banner,
Button,
Card,
Empty,
Image,
Input,
InputNumber,
Layout,
Modal,
Skeleton,
Space,
Tag,
Typography,
@@ -37,6 +41,7 @@ import {
Tabs,
TabPane,
} from '@douyinfe/semi-ui';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
import {
IconMail,
IconLock,
@@ -46,20 +51,12 @@ import {
IconBell,
IconGithubLogo,
IconKey,
IconCreditCard,
IconLink,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
import { Bell, Shield, Webhook, Globe, Settings, UserPlus, ShieldCheck } from 'lucide-react';
import {
getQuotaPerUnit,
renderQuota,
renderQuotaWithPrompt,
stringToColor,
} from '../helpers/render';
import TelegramLoginButton from 'react-telegram-login';
import { useTranslation } from 'react-i18next';
@@ -88,16 +85,14 @@ const PersonalSetting = () => {
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [affLink, setAffLink] = useState('');
const [systemToken, setSystemToken] = useState('');
const [models, setModels] = useState([]);
const [openTransfer, setOpenTransfer] = useState(false);
const [transferAmount, setTransferAmount] = useState(0);
const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
// Initialize from localStorage if available
const savedState = localStorage.getItem('modelsExpanded');
return savedState ? JSON.parse(savedState) : false;
});
const [activeModelCategory, setActiveModelCategory] = useState('all');
const MODELS_DISPLAY_COUNT = 25; // 默认显示的模型数量
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
@@ -107,6 +102,7 @@ const PersonalSetting = () => {
notificationEmail: '',
acceptUnsetModelRatioModel: false,
});
const [modelsLoading, setModelsLoading] = useState(true);
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
useEffect(() => {
@@ -123,8 +119,6 @@ const PersonalSetting = () => {
console.log(userState);
});
loadModels().then();
getAffLink().then();
setTransferAmount(getQuotaPerUnit());
}, []);
useEffect(() => {
@@ -176,17 +170,6 @@ const PersonalSetting = () => {
}
};
const getAffLink = async () => {
const res = await API.get('/api/user/aff');
const { success, message, data } = res.data;
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link);
} else {
showError(message);
}
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -198,21 +181,24 @@ const PersonalSetting = () => {
};
const loadModels = async () => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
if (success) {
if (data != null) {
setModels(data);
}
} else {
showError(message);
}
};
setModelsLoading(true);
const handleAffLinkClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(t('邀请链接已复制到剪切板'));
try {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
if (success) {
if (data != null) {
setModels(data);
}
} else {
showError(message);
}
} catch (error) {
showError(t('加载模型列表失败'));
} finally {
setModelsLoading(false);
}
};
const handleSystemTokenClick = async (e) => {
@@ -286,24 +272,6 @@ const PersonalSetting = () => {
setShowChangePasswordModal(false);
};
const transfer = async () => {
if (transferAmount < getQuotaPerUnit()) {
showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
return;
}
const res = await API.post(`/api/user/aff_transfer`, {
quota: transferAmount,
});
const { success, message } = res.data;
if (success) {
showSuccess(message);
setOpenTransfer(false);
getUserData().then();
} else {
showError(message);
}
};
const sendVerificationCode = async () => {
if (inputs.email === '') {
showError(t('请输入邮箱!'));
@@ -364,10 +332,6 @@ const PersonalSetting = () => {
return 'NA';
};
const handleCancel = () => {
setOpenTransfer(false);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
@@ -410,55 +374,12 @@ const PersonalSetting = () => {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gray-50">
<Layout>
<Layout.Content>
{/* 划转模态框 */}
<Modal
title={
<div className="flex items-center">
<IconCreditCard className="mr-2" />
{t('请输入要划转的数量')}
</div>
}
visible={openTransfer}
onOk={transfer}
onCancel={handleCancel}
maskClosable={false}
size={'small'}
centered={true}
>
<div className="space-y-4 py-4">
<div>
<Typography.Text strong className="block mb-2">
{t('可用额度')} {renderQuotaWithPrompt(userState?.user?.aff_quota)}
</Typography.Text>
<Input
value={userState?.user?.aff_quota}
disabled={true}
size="large"
className="!rounded-lg"
/>
</div>
<div>
<Typography.Text strong className="block mb-2">
{t('划转额度')} {renderQuotaWithPrompt(transferAmount)}{' '}
{t('最低') + renderQuota(getQuotaPerUnit())}
</Typography.Text>
<InputNumber
min={0}
value={transferAmount}
onChange={(value) => setTransferAmount(value)}
disabled={false}
size="large"
className="!rounded-lg w-full"
/>
</div>
</div>
</Modal>
<div className="flex justify-center">
<div className="w-full max-w-6xl">
<div className="w-full">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部用户信息区域 */}
@@ -604,17 +525,17 @@ const PersonalSetting = () => {
{/* 主内容区域 - 使用Tabs组织不同功能模块 */}
<div className="p-4">
<Tabs type='line' defaultActiveKey='models' className="modern-tabs">
{/* 模型与邀请Tab */}
{/* 可用模型Tab */}
<TabPane
tab={
<div className="flex items-center">
<Settings size={16} className="mr-2" />
{t('模型与邀请')}
{t('可用模型')}
</div>
}
itemKey='models'
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 py-4">
<div className="gap-6 py-4">
{/* 可用模型部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="flex items-center mb-4">
@@ -622,148 +543,176 @@ const PersonalSetting = () => {
<Settings size={20} className="text-purple-500" />
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('可用模型')}</Typography.Title>
<Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
<div className="text-gray-500 text-sm">{t('点击模型名称可复制')}</div>
</div>
</div>
<div className="bg-white rounded-lg p-3">
{models.length <= MODELS_DISPLAY_COUNT ? (
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
{modelsLoading ? (
// 骨架屏加载状态 - 模拟实际加载后的布局
<div className="space-y-4">
{/* 模拟分类标签 */}
<div className="mb-4" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
<div className="flex overflow-x-auto py-2 gap-2">
{Array.from({ length: 8 }).map((_, index) => (
<Skeleton.Button key={`cat-${index}`} style={{
width: index === 0 ? 130 : 100 + Math.random() * 50,
height: 36,
borderRadius: 8
}} />
))}
</div>
</div>
{/* 模拟模型标签列表 */}
<div className="flex flex-wrap gap-2">
{Array.from({ length: 20 }).map((_, index) => (
<Skeleton.Button
key={`model-${index}`}
style={{
width: 100 + Math.random() * 100,
height: 32,
borderRadius: 16,
margin: '4px'
}}
/>
))}
</Space>
) : (
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{models.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />}
>
{t('收起')}
</Tag>
</Space>
</Collapsible>
{!isModelsExpanded && (
<Space wrap>
{models
.slice(0, MODELS_DISPLAY_COUNT)
.map((model) => (
<Tag
key={model}
color={stringToColor(model)}
onClick={() => copyText(model)}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-lg"
>
{model}
</Tag>
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />}
>
{t('更多')} {models.length - MODELS_DISPLAY_COUNT} {t('个模型')}
</Tag>
</Space>
)}
</>
)}
</div>
</div>
{/* 邀请信息部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
<IconLink size={20} className="text-orange-500" />
</div>
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('邀请信息')}</Typography.Title>
<div className="text-gray-500 text-sm">{t('管理您的邀请链接和收益')}</div>
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('待使用收益')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_quota)}
</div>
<Button
type="primary"
theme="solid"
onClick={() => setOpenTransfer(true)}
size="small"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 mt-2 w-full"
icon={<IconCreditCard />}
>
{t('划转')}
</Button>
</Card>
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('总收益')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_history_quota)}
</div>
</Card>
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
shadows='hover'
>
<div className="text-gray-600 text-xs font-medium">{t('邀请人数')}</div>
<div className="text-gray-900 text-lg font-bold mt-1">
{userState?.user?.aff_count || 0}
</div>
</Card>
</div>
<div className="bg-white rounded-lg p-3">
<Typography.Text strong className="block mb-2 text-sm">{t('邀请链接')}</Typography.Text>
<Input
value={affLink}
onClick={handleAffLinkClick}
readOnly
size="large"
className="!rounded-lg"
prefix={<IconLink />}
) : models.length === 0 ? (
<div className="py-8">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('没有可用模型')}
style={{ padding: '24px 0' }}
/>
</div>
</div>
) : (
<>
{/* 模型分类标签页 */}
<div className="mb-4">
<Tabs
type="card"
activeKey={activeModelCategory}
onChange={key => setActiveModelCategory(key)}
className="mt-2"
>
{Object.entries(getModelCategories(t)).map(([key, category]) => {
// 计算该分类下的模型数量
const modelCount = key === 'all'
? models.length
: models.filter(model => category.filter({ model_name: model })).length;
if (modelCount === 0 && key !== 'all') return null;
return (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
<Tag
color={activeModelCategory === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
})}
</Tabs>
</div>
<div className="bg-white rounded-lg p-3">
{(() => {
// 根据当前选中的分类过滤模型
const categories = getModelCategories(t);
const filteredModels = activeModelCategory === 'all'
? models
: models.filter(model => categories[activeModelCategory].filter({ model_name: model }));
// 如果过滤后没有模型,显示空状态
if (filteredModels.length === 0) {
return (
<Empty
image={<IllustrationNoContent style={{ width: 120, height: 120 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 120, height: 120 }} />}
description={t('该分类下没有可用模型')}
style={{ padding: '16px 0' }}
/>
);
}
if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
return (
<Space wrap>
{filteredModels.map((model) => (
renderModelTag(model, {
size: 'large',
shape: 'circle',
onClick: () => copyText(model),
})
))}
</Space>
);
} else {
return (
<>
<Collapsible isOpen={isModelsExpanded}>
<Space wrap>
{filteredModels.map((model) => (
renderModelTag(model, {
size: 'large',
shape: 'circle',
onClick: () => copyText(model),
})
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(false)}
icon={<IconChevronUp />}
>
{t('收起')}
</Tag>
</Space>
</Collapsible>
{!isModelsExpanded && (
<Space wrap>
{filteredModels
.slice(0, MODELS_DISPLAY_COUNT)
.map((model) => (
renderModelTag(model, {
size: 'large',
shape: 'circle',
onClick: () => copyText(model),
})
))}
<Tag
color='grey'
type='light'
className="cursor-pointer !rounded-lg"
onClick={() => setIsModelsExpanded(true)}
icon={<IconChevronDown />}
>
{t('更多')} {filteredModels.length - MODELS_DISPLAY_COUNT} {t('个模型')}
</Tag>
</Space>
)}
</>
);
}
})()}
</div>
</>
)}
</div>
</div>
</TabPane>
@@ -809,7 +758,7 @@ const PersonalSetting = () => {
>
{userState.user && userState.user.email !== ''
? t('修改绑定')
: t('绑定邮箱')}
: t('绑定')}
</Button>
</div>
</Card>
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
import { API, showError, showSuccess } from '../../helpers/index.js';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
const RateLimitSetting = () => {
const { t } = useTranslation();
@@ -13,12 +13,12 @@ import {
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
API,
removeTrailingSlash,
showError,
showSuccess,
verifyJSON,
} from '../helpers/utils';
import { API } from '../helpers/api';
verifyJSON
} from '../../helpers';
import axios from 'axios';
const SystemSetting = () => {
@@ -5,14 +5,12 @@ import {
showInfo,
showSuccess,
timestamp2string,
} from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import {
renderGroup,
renderNumberWithPoint,
renderQuota,
} from '../helpers/render';
renderQuota
} from '../../helpers/index.js';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import {
Button,
Divider,
@@ -29,9 +27,9 @@ import {
Typography,
Checkbox,
Card,
Select,
Select
} from '@douyinfe/semi-ui';
import EditChannel from '../pages/Channel/EditChannel';
import EditChannel from '../../pages/Channel/EditChannel.js';
import {
IconList,
IconTreeTriangleDown,
@@ -48,8 +46,8 @@ import {
IconCopy,
IconSmallTriangleRight
} from '@douyinfe/semi-icons';
import { loadChannelModels } from './utils.js';
import EditTagModal from '../pages/Channel/EditTagModal.js';
import { loadChannelModels } from '../../helpers/index.js';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
const ChannelsTable = () => {
@@ -245,19 +243,16 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.ID,
title: t('ID'),
dataIndex: 'id',
width: 50,
},
{
key: COLUMN_KEYS.NAME,
title: t('名称'),
dataIndex: 'name',
width: 80,
},
{
key: COLUMN_KEYS.GROUP,
title: t('分组'),
dataIndex: 'group',
width: 180,
render: (text, record, index) => (
<div>
<Space spacing={2}>
@@ -277,7 +272,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'type',
width: 120,
render: (text, record, index) => {
if (record.children === undefined) {
return <>{renderType(text)}</>;
@@ -290,7 +284,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.STATUS,
title: t('状态'),
dataIndex: 'status',
width: 120,
render: (text, record, index) => {
if (text === 3) {
if (record.other_info === '') {
@@ -317,7 +310,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
width: 120,
render: (text, record, index) => (
<div>{renderResponseTime(text)}</div>
),
@@ -326,7 +318,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'),
dataIndex: 'expired_time',
width: 120,
render: (text, record, index) => {
if (record.children === undefined) {
return (
@@ -366,7 +357,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
dataIndex: 'priority',
width: 100,
render: (text, record, index) => {
if (record.children === undefined) {
return (
@@ -419,7 +409,6 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.WEIGHT,
title: t('权重'),
dataIndex: 'weight',
width: 100,
render: (text, record, index) => {
if (record.children === undefined) {
return (
@@ -472,7 +461,7 @@ const ChannelsTable = () => {
key: COLUMN_KEYS.OPERATE,
title: '',
dataIndex: 'operate',
width: 350,
fixed: 'right',
render: (text, record, index) => {
if (record.children === undefined) {
// 创建更多操作的下拉菜单项
@@ -880,11 +869,14 @@ const ChannelsTable = () => {
};
const copySelectedChannel = async (record) => {
const channelToCopy = record;
const channelToCopy = { ...record };
channelToCopy.name += t('_复制');
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
// 删除可能导致类型不匹配的字段
delete channelToCopy.test_time;
delete channelToCopy.response_time;
if (!channelToCopy) {
showError(t('渠道未找到,请刷新页面后重试。'));
return;
@@ -1635,13 +1627,15 @@ const ChannelsTable = () => {
/>
<Card
className="!rounded-2xl overflow-hidden"
className="!rounded-2xl"
title={renderHeader()}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
@@ -8,7 +8,20 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
renderAudioModelPrice,
renderClaudeLogContent,
renderClaudeModelPrice,
renderClaudeModelPriceSimple,
renderGroup,
renderLogContent,
renderModelPrice,
renderModelPriceSimple,
renderNumber,
renderQuota,
stringToColor,
getLogOther,
renderModelTag,
} from '../../helpers';
import {
Avatar,
@@ -29,31 +42,9 @@ import {
Input,
DatePicker,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import {
renderAudioModelPrice,
renderClaudeLogContent,
renderClaudeModelPrice,
renderClaudeModelPriceSimple,
renderGroup,
renderLogContent,
renderModelPrice,
renderModelPriceSimple,
renderNumber,
renderQuota,
stringToColor,
} from '../helpers/render';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { getLogOther } from '../helpers/other.js';
import {
IconRefresh,
IconSetting,
IconEyeOpened,
IconSearch,
IconCoinMoneyStroked,
IconPulse,
IconTypograph,
} from '@douyinfe/semi-icons';
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -204,19 +195,11 @@ const LogsTable = () => {
other?.upstream_model_name &&
other?.upstream_model_name !== '';
if (!modelMapped) {
return (
<Tag
color={stringToColor(record.model_name)}
size='large'
shape='circle'
onClick={(event) => {
copyText(event, record.model_name).then((r) => { });
}}
>
{' '}
{record.model_name}{' '}
</Tag>
);
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
});
} else {
return (
<>
@@ -225,48 +208,42 @@ const LogsTable = () => {
content={
<div style={{ padding: 10 }}>
<Space vertical align={'start'}>
<Tag
color={stringToColor(record.model_name)}
size='large'
shape='circle'
onClick={(event) => {
copyText(event, record.model_name).then((r) => { });
}}
>
{t('请求并计费模型')} {record.model_name}{' '}
</Tag>
<Tag
color={stringToColor(other.upstream_model_name)}
size='large'
shape='circle'
onClick={(event) => {
copyText(event, other.upstream_model_name).then(
(r) => { },
);
}}
>
{t('实际模型')} {other.upstream_model_name}{' '}
</Tag>
<div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>
{t('请求并计费模型')}:
</Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
})}
</div>
<div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>
{t('实际模型')}:
</Text>
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => { },
);
},
})}
</div>
</Space>
</div>
}
>
<Tag
color={stringToColor(record.model_name)}
size='large'
shape='circle'
onClick={(event) => {
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
}}
suffixIcon={
<IconRefresh
style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
},
suffixIcon: (
<IconForward
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
/>
}
>
{' '}
{record.model_name}{' '}
</Tag>
),
})}
</Popover>
</Space>
</>
@@ -374,13 +351,11 @@ const LogsTable = () => {
key: COLUMN_KEYS.TIME,
title: t('时间'),
dataIndex: 'timestamp2string',
width: 180,
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel',
width: 80,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
@@ -411,7 +386,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.USERNAME,
title: t('用户'),
dataIndex: 'username',
width: 150,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
@@ -438,7 +412,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TOKEN,
title: t('令牌'),
dataIndex: 'token_name',
width: 160,
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
<div>
@@ -464,7 +437,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.GROUP,
title: t('分组'),
dataIndex: 'group',
width: 120,
render: (text, record, index) => {
if (record.type === 0 || record.type === 2 || record.type === 5) {
if (record.group) {
@@ -497,7 +469,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'type',
width: 100,
render: (text, record, index) => {
return <>{renderType(text)}</>;
},
@@ -506,7 +477,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.MODEL,
title: t('模型'),
dataIndex: 'model_name',
width: 160,
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
<>{renderModelName(record)}</>
@@ -519,7 +489,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.USE_TIME,
title: t('用时/首字'),
dataIndex: 'use_time',
width: 160,
render: (text, record, index) => {
if (record.is_stream) {
let other = getLogOther(record.other);
@@ -548,7 +517,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PROMPT,
title: t('提示'),
dataIndex: 'prompt_tokens',
width: 100,
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
<>{<span> {text} </span>}</>
@@ -561,7 +529,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.COMPLETION,
title: t('补全'),
dataIndex: 'completion_tokens',
width: 100,
render: (text, record, index) => {
return parseInt(text) > 0 &&
(record.type === 0 || record.type === 2 || record.type === 5) ? (
@@ -575,7 +542,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.COST,
title: t('花费'),
dataIndex: 'quota',
width: 120,
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
<>{renderQuota(text, 6)}</>
@@ -588,7 +554,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.RETRY,
title: t('重试'),
dataIndex: 'retry',
width: 160,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
let content = t('渠道') + `${record.channel}`;
@@ -617,7 +582,7 @@ const LogsTable = () => {
key: COLUMN_KEYS.DETAILS,
title: t('详情'),
dataIndex: 'content',
width: 200,
fixed: 'right',
render: (text, record, index) => {
let other = getLogOther(record.other);
if (other == null || record.type !== 2) {
@@ -691,25 +656,25 @@ const LogsTable = () => {
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<div className='flex justify-end'>
<Button
theme="light"
theme='light'
onClick={() => initDefaultColumns()}
className="!rounded-full"
className='!rounded-full'
>
{t('重置')}
</Button>
<Button
theme="light"
theme='light'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
className='!rounded-full'
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
className='!rounded-full'
>
{t('确定')}
</Button>
@@ -729,7 +694,7 @@ const LogsTable = () => {
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
@@ -744,10 +709,7 @@ const LogsTable = () => {
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<div key={column.key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
@@ -1031,6 +993,9 @@ const LogsTable = () => {
other?.file_search || false,
other?.file_search_call_count || 0,
other?.file_search_price || 0,
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
);
}
expandDataLocal.push({
@@ -1125,13 +1090,20 @@ const LogsTable = () => {
return <Descriptions data={expandData[record.key]} />;
};
// 检查是否有任何记录有展开内容
const hasExpandableRows = () => {
return logs.some(
(log) => expandData[log.key] && expandData[log.key].length > 0,
);
};
return (
<>
{renderColumnSelector()}
<Card
className="!rounded-2xl overflow-hidden mb-4"
className='!rounded-2xl mb-4'
title={
<div className="flex flex-col w-full">
<div className='flex flex-col w-full'>
<Spin spinning={loadingStat}>
<Space>
<Tag
@@ -1174,15 +1146,15 @@ const LogsTable = () => {
</Space>
</Spin>
<Divider margin="12px" />
<Divider margin='12px' />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<div className='col-span-1 lg:col-span-2'>
<DatePicker
className="w-full"
className='w-full'
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
@@ -1198,7 +1170,7 @@ const LogsTable = () => {
<Select
value={logType.toString()}
placeholder={t('日志类型')}
className="!rounded-full"
className='!rounded-full'
onChange={(value) => {
setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value));
@@ -1218,7 +1190,7 @@ const LogsTable = () => {
placeholder={t('令牌名称')}
value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')}
className="!rounded-full"
className='!rounded-full'
showClear
/>
@@ -1227,7 +1199,7 @@ const LogsTable = () => {
placeholder={t('模型名称')}
value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')}
className="!rounded-full"
className='!rounded-full'
showClear
/>
@@ -1236,7 +1208,7 @@ const LogsTable = () => {
placeholder={t('分组')}
value={group}
onChange={(value) => handleInputChange(value, 'group')}
className="!rounded-full"
className='!rounded-full'
showClear
/>
@@ -1247,7 +1219,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
value={channel}
onChange={(value) => handleInputChange(value, 'channel')}
className="!rounded-full"
className='!rounded-full'
showClear
/>
<Input
@@ -1255,7 +1227,7 @@ const LogsTable = () => {
placeholder={t('用户名称')}
value={username}
onChange={(value) => handleInputChange(value, 'username')}
className="!rounded-full"
className='!rounded-full'
showClear
/>
</>
@@ -1263,14 +1235,14 @@ const LogsTable = () => {
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div className='flex justify-between items-center pt-2'>
<div></div>
<div className="flex gap-2">
<div className='flex gap-2'>
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
className='!rounded-full'
>
{t('查询')}
</Button>
@@ -1279,7 +1251,7 @@ const LogsTable = () => {
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
className='!rounded-full'
>
{t('列设置')}
</Button>
@@ -1288,17 +1260,22 @@ const LogsTable = () => {
</div>
</div>
}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
expandedRowRender={expandRowRender}
expandRowByClick={true}
{...(hasExpandableRows() && {
expandedRowRender: expandRowRender,
expandRowByClick: true,
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
})}
dataSource={logs}
rowKey='key'
loading={loading}
className="rounded-xl overflow-hidden"
size="middle"
scroll={{ x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
@@ -7,7 +7,7 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
} from '../../helpers';
import {
Button,
@@ -25,7 +25,7 @@ import {
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
@@ -374,7 +374,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
@@ -383,7 +382,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
width: 120,
render: (finish, record) => {
return renderDuration(record.submit_time, finish);
},
@@ -392,7 +390,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
width: 100,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
@@ -418,7 +415,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
width: 120,
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
@@ -427,7 +423,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'mj_id',
width: 200,
render: (text, record, index) => {
return <div>{text}</div>;
},
@@ -436,7 +431,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.SUBMIT_RESULT,
title: t('提交结果'),
dataIndex: 'code',
width: 120,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
@@ -446,7 +440,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
width: 120,
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
@@ -456,7 +449,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
width: 160,
render: (text, record, index) => {
return (
<div>
@@ -470,6 +462,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
style={{ minWidth: '200px' }}
/>
}
</div>
@@ -480,7 +473,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.IMAGE,
title: t('结果图片'),
dataIndex: 'image_url',
width: 120,
render: (text, record, index) => {
if (!text) {
return t('无');
@@ -501,7 +493,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PROMPT,
title: 'Prompt',
dataIndex: 'prompt',
width: 200,
render: (text, record, index) => {
if (!text) {
return t('无');
@@ -525,7 +516,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PROMPT_EN,
title: 'PromptEn',
dataIndex: 'prompt_en',
width: 200,
render: (text, record, index) => {
if (!text) {
return t('无');
@@ -549,7 +539,7 @@ const LogsTable = () => {
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
width: 160,
fixed: 'right',
render: (text, record, index) => {
if (!text) {
return t('无');
@@ -771,7 +761,7 @@ const LogsTable = () => {
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl overflow-hidden mb-4"
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">
@@ -864,13 +854,15 @@ const LogsTable = () => {
</div>
</div>
}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
@@ -1,5 +1,5 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
import { useTranslation } from 'react-i18next';
import {
@@ -24,11 +24,10 @@ import {
IconSearch,
IconCopy,
IconInfoCircle,
IconCrown,
IconLayers,
} from '@douyinfe/semi-icons';
import { UserContext } from '../context/User/index.js';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
import { MODEL_CATEGORIES } from '../constants';
const ModelPricing = () => {
const { t } = useTranslation();
@@ -116,29 +115,20 @@ const ModelPricing = () => {
return Number(aAvailable) - Number(bAvailable);
},
defaultSortOrder: 'descend',
width: 100,
},
{
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return (
<Tag
color='green'
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
);
return renderModelTag(text, {
onClick: () => {
copyText(text);
}
});
},
onFilter: (value, record) =>
record.model_name.toLowerCase().includes(value.toLowerCase()),
filteredValue,
width: 200,
},
{
title: t('计费类型'),
@@ -147,7 +137,6 @@ const ModelPricing = () => {
return renderQuotaType(parseInt(text));
},
sorter: (a, b) => a.quota_type - b.quota_type,
width: 120,
},
{
title: t('可用分组'),
@@ -224,7 +213,6 @@ const ModelPricing = () => {
);
return content;
},
width: 200,
},
{
title: t('模型价格'),
@@ -253,13 +241,12 @@ const ModelPricing = () => {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = (
<div className="text-gray-700">
${t('模型价格')}${price.toFixed(3)}
{t('模型价格')}${price.toFixed(3)}
</div>
);
}
return content;
},
width: 250,
},
];
@@ -326,7 +313,21 @@ const ModelPricing = () => {
refresh().then();
}, []);
const modelCategories = MODEL_CATEGORIES(t);
const modelCategories = getModelCategories(t);
const categoryCounts = useMemo(() => {
const counts = {};
if (models.length > 0) {
counts['all'] = models.length;
Object.entries(modelCategories).forEach(([key, category]) => {
if (key !== 'all') {
counts[key] = models.filter(model => category.filter(model)).length;
}
});
}
return counts;
}, [models, modelCategories]);
const renderArrow = (items, pos, handleArrowClick) => {
const style = {
@@ -345,15 +346,29 @@ const ModelPricing = () => {
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => (
<Dropdown.Item
key={item.itemKey}
onClick={() => setActiveKey(item.itemKey)}
icon={modelCategories[item.itemKey]?.icon}
>
{modelCategories[item.itemKey]?.label || item.itemKey}
</Dropdown.Item>
))}
{items.map(item => {
const key = item.itemKey;
const modelCount = categoryCounts[key] || 0;
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => setActiveKey(item.itemKey)}
icon={modelCategories[item.itemKey]?.icon}
>
<div className="flex items-center gap-2">
{modelCategories[item.itemKey]?.label || item.itemKey}
<Tag
color={activeKey === item.itemKey ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</div>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
@@ -387,18 +402,29 @@ const ModelPricing = () => {
>
{Object.entries(modelCategories)
.filter(([key]) => availableCategories.includes(key))
.map(([key, category]) => (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
</span>
}
itemKey={key}
key={key}
/>
))}
.map(([key, category]) => {
const modelCount = categoryCounts[key] || 0;
return (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
<Tag
color={activeKey === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
})}
</Tabs>
);
};
@@ -425,7 +451,7 @@ const ModelPricing = () => {
// 搜索和操作区组件
const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" shadows='hover'>
<Card className="!rounded-xl mb-6" bordered={false}>
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<Input
@@ -456,7 +482,7 @@ const ModelPricing = () => {
// 表格组件
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" shadows='hover'>
<Card className="!rounded-xl overflow-hidden" bordered={false}>
<Table
columns={columns}
dataSource={filteredModels}
@@ -481,7 +507,7 @@ const ModelPricing = () => {
), [filteredModels, loading, columns, rowSelection, pageSize, t]);
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center p-4 sm:p-6 md:p-8">
@@ -508,7 +534,7 @@ const ModelPricing = () => {
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
<div className="flex items-start">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
<IconCrown size="large" className="text-white" />
<IconLayers size="extra-large" className="text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
@@ -5,10 +5,10 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
@@ -33,7 +33,7 @@ import {
IconPlay,
IconMore,
} from '@douyinfe/semi-icons';
import EditRedemption from '../pages/Redemption/EditRedemption';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
@@ -78,18 +78,15 @@ const RedemptionsTable = () => {
{
title: t('ID'),
dataIndex: 'id',
width: 50,
},
{
title: t('名称'),
dataIndex: 'name',
width: 120,
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
width: 100,
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
@@ -97,7 +94,6 @@ const RedemptionsTable = () => {
{
title: t('额度'),
dataIndex: 'quota',
width: 100,
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
@@ -105,7 +101,6 @@ const RedemptionsTable = () => {
{
title: t('创建时间'),
dataIndex: 'created_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
@@ -113,7 +108,6 @@ const RedemptionsTable = () => {
{
title: t('兑换人ID'),
dataIndex: 'used_user_id',
width: 100,
render: (text, record, index) => {
return <div>{text === 0 ? t('无') : text}</div>;
},
@@ -121,7 +115,7 @@ const RedemptionsTable = () => {
{
title: '',
dataIndex: 'operate',
width: 300,
fixed: 'right',
render: (text, record, index) => {
// 创建更多操作的下拉菜单项
const moreMenuItems = [
@@ -499,13 +493,15 @@ const RedemptionsTable = () => {
></EditRedemption>
<Card
className="!rounded-2xl overflow-hidden"
className="!rounded-2xl"
title={renderHeader()}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
@@ -7,7 +7,7 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
} from '../../helpers';
import {
Button,
@@ -24,7 +24,7 @@ import {
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
@@ -289,7 +289,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
width: 180,
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
@@ -298,7 +297,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.FINISH_TIME,
title: t('结束时间'),
dataIndex: 'finish_time',
width: 180,
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
@@ -307,7 +305,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
width: 120,
render: (finish, record) => {
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
@@ -316,7 +313,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
width: 100,
className: isAdminUser ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
@@ -341,7 +337,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),
dataIndex: 'platform',
width: 120,
render: (text, record, index) => {
return <div>{renderPlatform(text)}</div>;
},
@@ -350,7 +345,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
width: 120,
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
@@ -359,7 +353,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'task_id',
width: 200,
render: (text, record, index) => {
return (
<Typography.Text
@@ -378,7 +371,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
width: 120,
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
@@ -387,7 +379,6 @@ const LogsTable = () => {
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
width: 160,
render: (text, record, index) => {
return (
<div>
@@ -404,6 +395,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '200px' }}
/>
)
}
@@ -415,7 +407,7 @@ const LogsTable = () => {
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
width: 160,
fixed: 'right',
render: (text, record, index) => {
if (!text) {
return t('无');
@@ -613,7 +605,7 @@ const LogsTable = () => {
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl overflow-hidden mb-4"
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">
@@ -702,13 +694,15 @@ const LogsTable = () => {
</div>
</div>
}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
@@ -1,15 +1,15 @@
import React, { useEffect, useState, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import {
API,
copy,
showError,
showSuccess,
timestamp2string,
} from '../helpers';
renderGroup,
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderQuota } from '../helpers/render';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
@@ -20,8 +20,6 @@ import {
Table,
Tag,
Input,
Divider,
Avatar,
} from '@douyinfe/semi-ui';
import {
@@ -35,13 +33,9 @@ import {
IconStop,
IconPlay,
IconMore,
IconMoneyExchangeStroked,
IconHistogram,
IconRotate,
} from '@douyinfe/semi-icons';
import EditToken from '../pages/Token/EditToken';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../context/User';
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
@@ -49,8 +43,6 @@ function renderTimestamp(timestamp) {
const TokensTable = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [userState, userDispatch] = useContext(UserContext);
const renderStatus = (status, model_limits_enabled = false) => {
switch (status) {
@@ -99,13 +91,11 @@ const TokensTable = () => {
{
title: t('名称'),
dataIndex: 'name',
width: 180,
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
width: 200,
render: (text, record, index) => {
return (
<div>
@@ -120,7 +110,6 @@ const TokensTable = () => {
{
title: t('已用额度'),
dataIndex: 'used_quota',
width: 120,
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
},
@@ -128,7 +117,6 @@ const TokensTable = () => {
{
title: t('剩余额度'),
dataIndex: 'remain_quota',
width: 120,
render: (text, record, index) => {
return (
<div>
@@ -148,7 +136,6 @@ const TokensTable = () => {
{
title: t('创建时间'),
dataIndex: 'created_time',
width: 180,
render: (text, record, index) => {
return <div>{renderTimestamp(text)}</div>;
},
@@ -156,7 +143,6 @@ const TokensTable = () => {
{
title: t('过期时间'),
dataIndex: 'expired_time',
width: 180,
render: (text, record, index) => {
return (
<div>
@@ -168,7 +154,7 @@ const TokensTable = () => {
{
title: '',
dataIndex: 'operate',
width: 320,
fixed: 'right',
render: (text, record, index) => {
let chats = localStorage.getItem('chats');
let chatsArray = [];
@@ -430,26 +416,9 @@ const TokensTable = () => {
window.open(url, '_blank');
};
// 获取用户数据
const getUserData = async () => {
try {
const res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
} catch (error) {
console.error('获取用户数据失败:', error);
showError(t('获取用户数据失败'));
}
};
useEffect(() => {
// 获取用户数据以确保显示正确的余额和使用量
getUserData();
loadTokens(0)
.then()
.catch((reason) => {
@@ -573,71 +542,6 @@ const TokensTable = () => {
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card
shadows='hover'
className="bg-blue-50 border-0 !rounded-2xl w-full"
headerLine={false}
onClick={() => navigate('/console/topup')}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="blue"
>
<IconMoneyExchangeStroked size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('当前余额')}</div>
<div className="text-xl font-semibold">{renderQuota(userState?.user?.quota)}</div>
</div>
</div>
</Card>
<Card
shadows='hover'
className="bg-purple-50 border-0 !rounded-2xl w-full"
headerLine={false}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="purple"
>
<IconHistogram size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('累计消费')}</div>
<div className="text-xl font-semibold">{renderQuota(userState?.user?.used_quota)}</div>
</div>
</div>
</Card>
<Card
shadows='hover'
className="bg-green-50 border-0 !rounded-2xl w-full"
headerLine={false}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color="green"
>
<IconRotate size="large" />
</Avatar>
<div>
<div className="text-sm text-gray-500">{t('请求次数')}</div>
<div className="text-xl font-semibold">{userState?.user?.request_count || 0}</div>
</div>
</div>
</Card>
</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">
<Button
@@ -672,7 +576,7 @@ const TokensTable = () => {
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
{t('复制所选令牌到剪贴板')}
</Button>
</div>
@@ -720,13 +624,15 @@ const TokensTable = () => {
></EditToken>
<Card
className="!rounded-2xl overflow-hidden"
className="!rounded-2xl"
title={renderHeader()}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess } from '../helpers';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
import {
Button,
Card,
@@ -25,10 +25,9 @@ import {
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
import AddUser from '../pages/User/AddUser';
import EditUser from '../pages/User/EditUser';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
@@ -88,17 +87,14 @@ const UsersTable = () => {
{
title: 'ID',
dataIndex: 'id',
width: 50,
},
{
title: t('用户名'),
dataIndex: 'username',
width: 100,
},
{
title: t('分组'),
dataIndex: 'group',
width: 100,
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
@@ -106,7 +102,6 @@ const UsersTable = () => {
{
title: t('统计信息'),
dataIndex: 'info',
width: 280,
render: (text, record, index) => {
return (
<div>
@@ -128,7 +123,6 @@ const UsersTable = () => {
{
title: t('邀请信息'),
dataIndex: 'invite',
width: 250,
render: (text, record, index) => {
return (
<div>
@@ -150,7 +144,6 @@ const UsersTable = () => {
{
title: t('角色'),
dataIndex: 'role',
width: 120,
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
@@ -158,7 +151,6 @@ const UsersTable = () => {
{
title: t('状态'),
dataIndex: 'status',
width: 100,
render: (text, record, index) => {
return (
<div>
@@ -174,7 +166,7 @@ const UsersTable = () => {
{
title: '',
dataIndex: 'operate',
width: 150,
fixed: 'right',
render: (text, record, index) => {
if (record.DeletedAt !== null) {
return <></>;
@@ -550,13 +542,15 @@ const UsersTable = () => {
></EditUser>
<Card
className="!rounded-2xl overflow-hidden"
className="!rounded-2xl"
title={renderHeader()}
shadows='hover'
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={users}
scroll={{ x: 'max-content' }}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
-76
View File
@@ -1,76 +0,0 @@
import { API, showError } from '../helpers';
export async function getOAuthState() {
let path = '/api/oauth/state';
let affCode = localStorage.getItem('aff');
if (affCode && affCode.length > 0) {
path += `?aff=${affCode}`;
}
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = 'code';
const scope = 'openid profile email';
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) {
window.open(url);
} else {
window.location.href = url;
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
if (!channelModels[type]) {
return [];
}
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}
+1 -1
View File
@@ -2,4 +2,4 @@ export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './model.constants';
export * from './playground.constants';
-145
View File
@@ -1,145 +0,0 @@
import {
OpenAI,
Claude,
Gemini,
Moonshot,
Zhipu,
Qwen,
DeepSeek,
Minimax,
Wenxin,
Spark,
Midjourney,
Hunyuan,
Cohere,
Cloudflare,
Ai360,
Yi,
Jina,
Mistral,
XAI,
Ollama,
Doubao,
} from '@lobehub/icons';
export const MODEL_CATEGORIES = (t) => ({
all: {
label: t('全部模型'),
icon: null,
filter: () => true
},
openai: {
label: 'OpenAI',
icon: <OpenAI />,
filter: (model) => model.model_name.toLowerCase().includes('gpt') ||
model.model_name.toLowerCase().includes('dall-e') ||
model.model_name.toLowerCase().includes('whisper') ||
model.model_name.toLowerCase().includes('tts') ||
model.model_name.toLowerCase().includes('text-') ||
model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada')
},
anthropic: {
label: 'Anthropic',
icon: <Claude.Color />,
filter: (model) => model.model_name.toLowerCase().includes('claude')
},
gemini: {
label: 'Gemini',
icon: <Gemini.Color />,
filter: (model) => model.model_name.toLowerCase().includes('gemini')
},
moonshot: {
label: 'Moonshot',
icon: <Moonshot />,
filter: (model) => model.model_name.toLowerCase().includes('moonshot')
},
zhipu: {
label: t('智谱'),
icon: <Zhipu.Color />,
filter: (model) => model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-')
},
qwen: {
label: t('通义千问'),
icon: <Qwen.Color />,
filter: (model) => model.model_name.toLowerCase().includes('qwen')
},
deepseek: {
label: 'DeepSeek',
icon: <DeepSeek.Color />,
filter: (model) => model.model_name.toLowerCase().includes('deepseek')
},
minimax: {
label: 'MiniMax',
icon: <Minimax.Color />,
filter: (model) => model.model_name.toLowerCase().includes('abab')
},
baidu: {
label: t('文心一言'),
icon: <Wenxin.Color />,
filter: (model) => model.model_name.toLowerCase().includes('ernie')
},
xunfei: {
label: t('讯飞星火'),
icon: <Spark.Color />,
filter: (model) => model.model_name.toLowerCase().includes('spark')
},
midjourney: {
label: 'Midjourney',
icon: <Midjourney />,
filter: (model) => model.model_name.toLowerCase().includes('mj_')
},
tencent: {
label: t('腾讯混元'),
icon: <Hunyuan.Color />,
filter: (model) => model.model_name.toLowerCase().includes('hunyuan')
},
cohere: {
label: 'Cohere',
icon: <Cohere.Color />,
filter: (model) => model.model_name.toLowerCase().includes('command')
},
cloudflare: {
label: 'Cloudflare',
icon: <Cloudflare.Color />,
filter: (model) => model.model_name.toLowerCase().includes('@cf/')
},
ai360: {
label: t('360智脑'),
icon: <Ai360.Color />,
filter: (model) => model.model_name.toLowerCase().includes('360')
},
yi: {
label: t('零一万物'),
icon: <Yi.Color />,
filter: (model) => model.model_name.toLowerCase().includes('yi')
},
jina: {
label: 'Jina',
icon: <Jina />,
filter: (model) => model.model_name.toLowerCase().includes('jina')
},
mistral: {
label: 'Mistral AI',
icon: <Mistral.Color />,
filter: (model) => model.model_name.toLowerCase().includes('mistral')
},
xai: {
label: 'xAI',
icon: <XAI />,
filter: (model) => model.model_name.toLowerCase().includes('grok')
},
llama: {
label: 'Llama',
icon: <Ollama />,
filter: (model) => model.model_name.toLowerCase().includes('llama')
},
doubao: {
label: t('豆包'),
icon: <Doubao.Color />,
filter: (model) => model.model_name.toLowerCase().includes('doubao')
}
});
+1 -1
View File
@@ -2,7 +2,7 @@
import React, { useReducer, useEffect, useMemo, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import { isMobile as getIsMobile } from '../../helpers/index.js';
import { isMobile as getIsMobile } from '../../helpers';
// Action Types
const ACTION_TYPES = {
+184 -1
View File
@@ -1,5 +1,6 @@
import { getUserIdFromLocalStorage, showError } from './utils';
import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils';
import axios from 'axios';
import { MESSAGE_ROLES } from '../constants/playground.constants';
export let API = axios.create({
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -29,3 +30,185 @@ API.interceptors.response.use(
showError(error);
},
);
// playground
// 构建API请求负载
export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
const processedMessages = messages
.filter(isValidMessage)
.map(formatMessageForAPI)
.filter(Boolean);
// 如果有系统提示,插入到消息开头
if (systemPrompt && systemPrompt.trim()) {
processedMessages.unshift({
role: MESSAGE_ROLES.SYSTEM,
content: systemPrompt.trim()
});
}
const payload = {
model: inputs.model,
messages: processedMessages,
stream: inputs.stream,
};
// 添加启用的参数
const parameterMappings = {
temperature: 'temperature',
top_p: 'top_p',
max_tokens: 'max_tokens',
frequency_penalty: 'frequency_penalty',
presence_penalty: 'presence_penalty',
seed: 'seed'
};
Object.entries(parameterMappings).forEach(([key, param]) => {
if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
payload[param] = inputs[param];
}
});
return payload;
};
// 处理API错误响应
export const handleApiError = (error, response = null) => {
const errorInfo = {
error: error.message || '未知错误',
timestamp: new Date().toISOString(),
stack: error.stack
};
if (response) {
errorInfo.status = response.status;
errorInfo.statusText = response.statusText;
}
if (error.message.includes('HTTP error')) {
errorInfo.details = '服务器返回了错误状态码';
} else if (error.message.includes('Failed to fetch')) {
errorInfo.details = '网络连接失败或服务器无响应';
}
return errorInfo;
};
// 处理模型数据
export const processModelsData = (data, currentModel) => {
const modelOptions = data.map(model => ({
label: model,
value: model,
}));
const hasCurrentModel = modelOptions.some(option => option.value === currentModel);
const selectedModel = hasCurrentModel && modelOptions.length > 0
? currentModel
: modelOptions[0]?.value;
return { modelOptions, selectedModel };
};
// 处理分组数据
export const processGroupsData = (data, userGroup) => {
let groupOptions = Object.entries(data).map(([group, info]) => ({
label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
value: group,
ratio: info.ratio,
fullLabel: info.desc,
}));
if (groupOptions.length === 0) {
groupOptions = [{
label: '用户分组',
value: '',
ratio: 1,
}];
} else if (userGroup) {
const userGroupIndex = groupOptions.findIndex(g => g.value === userGroup);
if (userGroupIndex > -1) {
const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
groupOptions.unshift(userGroupOption);
}
}
return groupOptions;
};
// 原来components中的utils.js
export async function getOAuthState() {
let path = '/api/oauth/state';
let affCode = localStorage.getItem('aff');
if (affCode && affCode.length > 0) {
path += `?aff=${affCode}`;
}
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = 'code';
const scope = 'openid profile email';
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) {
window.open(url);
} else {
window.location.href = url;
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
if (!channelModels[type]) {
return [];
}
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}
-10
View File
@@ -1,10 +0,0 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { Authorization: 'Bearer ' + user.token };
} else {
return {};
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { history } from './history';
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { Authorization: 'Bearer ' + user.token };
} else {
return {};
}
}
export const AuthRedirect = ({ children }) => {
const user = localStorage.getItem('user');
if (user) {
return <Navigate to="/console" replace />;
}
return children;
};
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };
+5 -1
View File
@@ -1,4 +1,8 @@
export * from './history';
export * from './auth-header';
export * from './auth';
export * from './utils';
export * from './api';
export * from './render';
export * from './log';
export * from './data';
export * from './token';
@@ -4,4 +4,4 @@ export function getLogOther(otherStr) {
}
let other = JSON.parse(otherStr);
return other;
}
}
+642 -214
View File
@@ -1,6 +1,479 @@
import i18next from 'i18next';
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
import { copy, isMobile, showSuccess } from './utils.js';
import { copy, isMobile, showSuccess } from './utils';
import { visit } from 'unist-util-visit';
import {
OpenAI,
Claude,
Gemini,
Moonshot,
Zhipu,
Qwen,
DeepSeek,
Minimax,
Wenxin,
Spark,
Midjourney,
Hunyuan,
Cohere,
Cloudflare,
Ai360,
Yi,
Jina,
Mistral,
XAI,
Ollama,
Doubao,
} from '@lobehub/icons';
import {
LayoutDashboard,
TerminalSquare,
MessageSquare,
Key,
BarChart3,
Image as ImageIcon,
CheckSquare,
CreditCard,
Layers,
Gift,
User,
Settings,
CircleUser,
} from 'lucide-react';
// 侧边栏图标颜色映射
export const sidebarIconColors = {
dashboard: '#4F46E5', // 紫蓝色
terminal: '#10B981', // 绿色
message: '#06B6D4', // 青色
key: '#3B82F6', // 蓝色
chart: '#8B5CF6', // 紫色
image: '#EC4899', // 粉色
check: '#F59E0B', // 琥珀色
credit: '#F97316', // 橙色
layers: '#EF4444', // 红色
gift: '#F43F5E', // 玫红色
user: '#6366F1', // 靛蓝色
settings: '#6B7280', // 灰色
};
// 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) {
const size = 16;
const strokeWidth = 2;
const commonProps = {
size,
strokeWidth,
className: `transition-colors duration-200 ${selected ? 'transition-transform duration-200 scale-105' : ''}`,
};
// 根据不同的key返回不同的图标
switch (key) {
case 'detail':
return (
<LayoutDashboard
{...commonProps}
color={selected ? sidebarIconColors.dashboard : 'currentColor'}
/>
);
case 'playground':
return (
<TerminalSquare
{...commonProps}
color={selected ? sidebarIconColors.terminal : 'currentColor'}
/>
);
case 'chat':
return (
<MessageSquare
{...commonProps}
color={selected ? sidebarIconColors.message : 'currentColor'}
/>
);
case 'token':
return (
<Key
{...commonProps}
color={selected ? sidebarIconColors.key : 'currentColor'}
/>
);
case 'log':
return (
<BarChart3
{...commonProps}
color={selected ? sidebarIconColors.chart : 'currentColor'}
/>
);
case 'midjourney':
return (
<ImageIcon
{...commonProps}
color={selected ? sidebarIconColors.image : 'currentColor'}
/>
);
case 'task':
return (
<CheckSquare
{...commonProps}
color={selected ? sidebarIconColors.check : 'currentColor'}
/>
);
case 'topup':
return (
<CreditCard
{...commonProps}
color={selected ? sidebarIconColors.credit : 'currentColor'}
/>
);
case 'channel':
return (
<Layers
{...commonProps}
color={selected ? sidebarIconColors.layers : 'currentColor'}
/>
);
case 'redemption':
return (
<Gift
{...commonProps}
color={selected ? sidebarIconColors.gift : 'currentColor'}
/>
);
case 'user':
case 'personal':
return (
<User
{...commonProps}
color={selected ? sidebarIconColors.user : 'currentColor'}
/>
);
case 'setting':
return (
<Settings
{...commonProps}
color={selected ? sidebarIconColors.settings : 'currentColor'}
/>
);
default:
return (
<CircleUser
{...commonProps}
color={selected ? sidebarIconColors.user : 'currentColor'}
/>
);
}
}
// 获取模型分类
export const getModelCategories = (() => {
let categoriesCache = null;
let lastLocale = null;
return (t) => {
const currentLocale = i18next.language;
if (categoriesCache && lastLocale === currentLocale) {
return categoriesCache;
}
categoriesCache = {
all: {
label: t('全部模型'),
icon: null,
filter: () => true,
},
openai: {
label: 'OpenAI',
icon: <OpenAI />,
filter: (model) =>
model.model_name.toLowerCase().includes('gpt') ||
model.model_name.toLowerCase().includes('dall-e') ||
model.model_name.toLowerCase().includes('whisper') ||
model.model_name.toLowerCase().includes('tts') ||
model.model_name.toLowerCase().includes('text-') ||
model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada') ||
model.model_name.toLowerCase().includes('o1') ||
model.model_name.toLowerCase().includes('o3') ||
model.model_name.toLowerCase().includes('o4'),
},
anthropic: {
label: 'Anthropic',
icon: <Claude.Color />,
filter: (model) => model.model_name.toLowerCase().includes('claude'),
},
gemini: {
label: 'Gemini',
icon: <Gemini.Color />,
filter: (model) => model.model_name.toLowerCase().includes('gemini'),
},
moonshot: {
label: 'Moonshot',
icon: <Moonshot />,
filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
},
zhipu: {
label: t('智谱'),
icon: <Zhipu.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-'),
},
qwen: {
label: t('通义千问'),
icon: <Qwen.Color />,
filter: (model) => model.model_name.toLowerCase().includes('qwen'),
},
deepseek: {
label: 'DeepSeek',
icon: <DeepSeek.Color />,
filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
},
minimax: {
label: 'MiniMax',
icon: <Minimax.Color />,
filter: (model) => model.model_name.toLowerCase().includes('abab'),
},
baidu: {
label: t('文心一言'),
icon: <Wenxin.Color />,
filter: (model) => model.model_name.toLowerCase().includes('ernie'),
},
xunfei: {
label: t('讯飞星火'),
icon: <Spark.Color />,
filter: (model) => model.model_name.toLowerCase().includes('spark'),
},
midjourney: {
label: 'Midjourney',
icon: <Midjourney />,
filter: (model) => model.model_name.toLowerCase().includes('mj_'),
},
tencent: {
label: t('腾讯混元'),
icon: <Hunyuan.Color />,
filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
},
cohere: {
label: 'Cohere',
icon: <Cohere.Color />,
filter: (model) => model.model_name.toLowerCase().includes('command'),
},
cloudflare: {
label: 'Cloudflare',
icon: <Cloudflare.Color />,
filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
},
ai360: {
label: t('360智脑'),
icon: <Ai360.Color />,
filter: (model) => model.model_name.toLowerCase().includes('360'),
},
yi: {
label: t('零一万物'),
icon: <Yi.Color />,
filter: (model) => model.model_name.toLowerCase().includes('yi'),
},
jina: {
label: 'Jina',
icon: <Jina />,
filter: (model) => model.model_name.toLowerCase().includes('jina'),
},
mistral: {
label: 'Mistral AI',
icon: <Mistral.Color />,
filter: (model) => model.model_name.toLowerCase().includes('mistral'),
},
xai: {
label: 'xAI',
icon: <XAI />,
filter: (model) => model.model_name.toLowerCase().includes('grok'),
},
llama: {
label: 'Llama',
icon: <Ollama />,
filter: (model) => model.model_name.toLowerCase().includes('llama'),
},
doubao: {
label: t('豆包'),
icon: <Doubao.Color />,
filter: (model) => model.model_name.toLowerCase().includes('doubao'),
},
};
lastLocale = currentLocale;
return categoriesCache;
};
})();
// 颜色列表
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA',
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE',
];
// 模型颜色映射
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
// 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
// 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
'text-ada-001': 'rgb(255,192,203)', // 粉红色
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
// 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
'whisper-1': 'rgb(245,245,220)', // 米色
'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = (hash << 5) - hash + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
let i = sum % colors.length;
return colors[i];
}
// 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) {
const {
color,
size = 'large',
shape = 'circle',
onClick,
suffixIcon,
} = options;
const categories = getModelCategories(i18next.t);
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: modelName })) {
icon = category.icon;
break;
}
}
return (
<Tag
color={color || stringToColor(modelName)}
prefixIcon={icon}
suffixIcon={suffixIcon}
size={size}
shape={shape}
onClick={onClick}
>
{modelName}
</Tag>
);
}
export function renderText(text, limit) {
if (text.length > limit) {
@@ -324,6 +797,9 @@ export function renderModelPrice(
fileSearch = false,
fileSearchCallCount = 0,
fileSearchPrice = 0,
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
) {
if (modelPrice !== -1) {
return i18next.t(
@@ -351,9 +827,12 @@ export function renderModelPrice(
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
@@ -362,8 +841,11 @@ export function renderModelPrice(
<>
<article>
<p>
{i18next.t('输入价格:${{price}} / 1M tokens', {
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
price: inputRatioPrice,
audioPrice: audioInputSeperatePrice
? `,音频 $${audioInputPrice} / 1M tokens`
: '',
})}
</p>
<p>
@@ -417,96 +899,93 @@ export function renderModelPrice(
)}
<p></p>
<p>
{cacheTokens > 0 && !image && !webSearch && !fileSearch
? i18next.t(
'输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{(() => {
// 构建输入部分描述
let inputDesc = '';
if (image && imageOutputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
},
);
} else if (cacheTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
cachePrice: cacheRatioPrice,
},
)
: image && imageOutputTokens > 0 && !webSearch && !fileSearch
? i18next.t(
'输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)
: webSearch && webSearchCallCount > 0 && !image && !fileSearch
);
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
{
nonAudioInput: inputTokens - audioInputTokens,
audioInput: audioInputTokens,
price: inputRatioPrice,
audioPrice: audioInputPrice,
},
);
} else {
inputDesc = i18next.t(
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
{
input: inputTokens,
price: inputRatioPrice,
},
);
}
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
{
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
},
);
// 构建额外服务描述
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
total: price.toFixed(6),
},
)
: fileSearch &&
fileSearchCallCount > 0 &&
!image &&
!webSearch
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6),
},
)
: webSearch &&
webSearchCallCount > 0 &&
fileSearch &&
fileSearchCallCount > 0 &&
!image
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6),
},
)
: i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)}
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
},
)
: '',
].join('');
return i18next.t(
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
{
inputDesc,
outputDesc,
extraServices,
total: price.toFixed(6),
},
);
})()}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
@@ -799,137 +1278,6 @@ export function renderQuotaWithPrompt(quota, digits) {
return '';
}
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA',
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE',
];
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
// 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
// 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
'text-ada-001': 'rgb(255,192,203)', // 粉红色
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
// 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
'whisper-1': 'rgb(245,245,220)', // 米色
'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = (hash << 5) - hash + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
let i = sum % colors.length;
return colors[i];
}
export function renderClaudeModelPrice(
inputTokens,
completionTokens,
@@ -1128,3 +1476,83 @@ export function renderClaudeModelPriceSimple(
}
}
}
/**
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
* 仅在流式渲染阶段使用,避免已渲染文字重复动画。
*/
export function rehypeSplitWordsIntoSpans(options = {}) {
const { previousContentLength = 0 } = options;
return (tree) => {
let currentCharCount = 0; // 当前已处理的字符数
visit(tree, 'element', (node) => {
if (
['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
node.tagName,
) &&
node.children
) {
const newChildren = [];
node.children.forEach((child) => {
if (child.type === 'text') {
try {
// 使用 Intl.Segmenter 精准拆分中英文及标点
const segmenter = new Intl.Segmenter('zh', {
granularity: 'word',
});
const segments = segmenter.segment(child.value);
Array.from(segments)
.map((seg) => seg.segment)
.filter(Boolean)
.forEach((word) => {
const wordStartPos = currentCharCount;
const wordEndPos = currentCharCount + word.length;
// 判断这个词是否是新增的(在 previousContentLength 之后)
const isNewContent = wordStartPos >= previousContentLength;
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: isNewContent ? ['animate-fade-in'] : [],
},
children: [{ type: 'text', value: word }],
});
currentCharCount = wordEndPos;
});
} catch (_) {
// Fallback:如果浏览器不支持 Segmenter
const textStartPos = currentCharCount;
const isNewContent = textStartPos >= previousContentLength;
if (isNewContent) {
// 新内容,添加动画
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: ['animate-fade-in'],
},
children: [{ type: 'text', value: child.value }],
});
} else {
// 旧内容,不添加动画
newChildren.push(child);
}
currentCharCount += child.value.length;
}
} else {
newChildren.push(child);
}
});
node.children = newChildren;
}
});
};
}
+45
View File
@@ -0,0 +1,45 @@
import { API } from './api';
/**
* 获取可用的token keys
* @returns {Promise<string[]>} 返回active状态的token key数组
*/
export async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
}
}
/**
* 获取服务器地址
* @returns {string} 服务器地址
*/
export function getServerAddress() {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (error) {
console.error('Failed to parse status from localStorage:', error);
}
}
if (!serverAddress) {
serverAddress = window.location.origin;
}
return serverAddress;
}
+163
View File
@@ -2,6 +2,7 @@ import { Toast } from '@douyinfe/semi-ui';
import { toastConstants } from '../constants';
import React from 'react';
import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -283,3 +284,165 @@ export function compareObjects(oldObject, newObject) {
return changedProperties;
}
// playground message
// 生成唯一ID
let messageId = 4;
export const generateMessageId = () => `${messageId++}`;
// 提取消息中的文本内容
export const getTextContent = (message) => {
if (!message || !message.content) return '';
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
return textContent?.text || '';
}
return typeof message.content === 'string' ? message.content : '';
};
// 处理 think 标签
export const processThinkTags = (content, reasoningContent = '') => {
if (!content || !content.includes('<think>')) {
return { content, reasoningContent };
}
const thoughts = [];
const replyParts = [];
let lastIndex = 0;
let match;
THINK_TAG_REGEX.lastIndex = 0;
while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
replyParts.push(content.substring(lastIndex, match.index));
thoughts.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(content.substring(lastIndex));
const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
const thoughtsStr = thoughts.join('\n\n---\n\n');
const processedReasoningContent = reasoningContent && thoughtsStr
? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
: reasoningContent || thoughtsStr;
return {
content: processedContent,
reasoningContent: processedReasoningContent
};
};
// 处理未完成的 think 标签
export const processIncompleteThinkTags = (content, reasoningContent = '') => {
if (!content) return { content: '', reasoningContent };
const lastOpenThinkIndex = content.lastIndexOf('<think>');
if (lastOpenThinkIndex === -1) {
return processThinkTags(content, reasoningContent);
}
const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
const cleanContent = content.substring(0, lastOpenThinkIndex);
const processedReasoningContent = unclosedThought
? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
: reasoningContent;
return processThinkTags(cleanContent, processedReasoningContent);
}
return processThinkTags(content, reasoningContent);
};
// 构建消息内容(包含图片)
export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
if (!textContent && (!imageUrls || imageUrls.length === 0)) {
return '';
}
const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
if (imageEnabled && validImageUrls.length > 0) {
return [
{ type: 'text', text: textContent || '' },
...validImageUrls.map(url => ({
type: 'image_url',
image_url: { url: url.trim() }
}))
];
}
return textContent || '';
};
// 创建新消息
export const createMessage = (role, content, options = {}) => ({
role,
content,
createAt: Date.now(),
id: generateMessageId(),
...options
});
// 创建加载中的助手消息
export const createLoadingAssistantMessage = () => createMessage(
MESSAGE_ROLES.ASSISTANT,
'',
{
reasoningContent: '',
isReasoningExpanded: true,
isThinkingComplete: false,
hasAutoCollapsed: false,
status: 'loading'
}
);
// 检查消息是否包含图片
export const hasImageContent = (message) => {
return message &&
Array.isArray(message.content) &&
message.content.some(item => item.type === 'image_url');
};
// 格式化消息用于API请求
export const formatMessageForAPI = (message) => {
if (!message) return null;
return {
role: message.role,
content: message.content
};
};
// 验证消息是否有效
export const isValidMessage = (message) => {
return message &&
message.role &&
(message.content || message.content === '');
};
// 获取最后一条用户消息
export const getLastUserMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.USER) {
return messages[i];
}
}
return null;
};
// 获取最后一条助手消息
export const getLastAssistantMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
return messages[i];
}
}
return null;
};
+4 -7
View File
@@ -1,20 +1,17 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SSE } from 'sse';
import { getUserIdFromLocalStorage } from '../helpers/index.js';
import {
API_ENDPOINTS,
MESSAGE_STATUS,
DEBUG_TABS
} from '../utils/constants';
import {
buildApiPayload,
handleApiError
} from '../utils/apiUtils';
} from '../constants/playground.constants';
import {
getUserIdFromLocalStorage,
handleApiError,
processThinkTags,
processIncompleteThinkTags
} from '../utils/messageUtils';
} from '../helpers';
export const useApiRequest = (
setMessage,
+2 -3
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../helpers/index.js';
import { API_ENDPOINTS } from '../utils/constants';
import { processModelsData, processGroupsData } from '../utils/apiUtils';
import { API, processModelsData, processGroupsData } from '../helpers';
import { API_ENDPOINTS } from '../constants/playground.constants';
export const useDataLoader = (
userState,
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent } from '../utils/messageUtils';
import { ERROR_MESSAGES } from '../utils/constants';
import { getTextContent } from '../helpers';
import { ERROR_MESSAGES } from '../constants/playground.constants';
export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
const { t } = useTranslation();
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback, useState, useRef } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../utils/messageUtils';
import { MESSAGE_ROLES } from '../utils/constants';
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers';
import { MESSAGE_ROLES } from '../constants/playground.constants';
export const useMessageEdit = (
setMessage,
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../utils/constants';
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants';
import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
import { processIncompleteThinkTags } from '../utils/messageUtils';
import { processIncompleteThinkTags } from '../helpers';
export const usePlaygroundState = () => {
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useRef } from 'react';
import { MESSAGE_ROLES } from '../utils/constants';
import { MESSAGE_ROLES } from '../constants/playground.constants';
export const useSyncMessageAndCustomBody = (
customRequestMode,
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { fetchTokenKeys, getServerAddress } from '../helpers/token';
import { showError } from '../helpers';
export function useTokenKeys(id) {
const [keys, setKeys] = useState([]);
const [serverAddress, setServerAddress] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAllData = async () => {
const fetchedKeys = await fetchTokenKeys();
if (fetchedKeys.length === 0) {
showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
setTimeout(() => {
window.location.href = '/console/token';
}, 1500); // 延迟 1.5 秒后跳转
}
setKeys(fetchedKeys);
setIsLoading(false);
const address = getServerAddress();
setServerAddress(address);
};
loadAllData();
}, []);
return { keys, serverAddress, isLoading };
}
+31 -7
View File
@@ -6,14 +6,15 @@
"或": "or",
"登 录": "Log In",
"注 册": "Sign Up",
"使用 邮箱 登录": "Sign in with Email",
"使用 邮箱或用户名 登录": "Sign in with Email or Username",
"使用 GitHub 继续": "Continue with GitHub",
"使用 OIDC 继续": "Continue with OIDC",
"使用 微信 继续": "Continue with WeChat",
"使用 LinuxDO 继续": "Continue with LinuxDO",
"使用 邮箱 注册": "Sign up with Email",
"使用 用户名 注册": "Sign up with Username",
"其他登录选项": "Other login options",
"其他注册选项": "Other registration options",
"请输入您的用户名或邮箱地址": "Please enter your username or email address",
"请输入您的邮箱地址": "Please enter your email address",
"请输入您的密码": "Please enter your password",
"继续": "Continue",
@@ -585,7 +586,7 @@
"您正在使用默认密码!": "You are using the default password!",
"请立刻修改默认密码!": "Please change the default password immediately!",
"请输入用户名和密码!": "Please enter username and password!",
"用户名/邮箱": "Username/email",
"用户名邮箱": "Username or email",
"微信扫码登录": "WeChat scan code to log in",
"刷新成功": "Refresh successful",
"刷新失败": "Refresh failed",
@@ -742,7 +743,6 @@
"无效的用户单独并发限制数据": "Invalid user individual concurrency limit data",
"未绑定": "Not bound",
"修改绑定": "Modify binding",
"绑定邮箱": "Bind email",
"确认新密码": "Confirm new password",
"历史消耗": "Consumption",
"查看": "Check",
@@ -1367,7 +1367,7 @@
"提示 {{nonCacheInput}} tokens + 缓存 {{cacheInput}} tokens * {{cacheRatio}} / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}": "Prompt {{nonCacheInput}} tokens + cache {{cacheInput}} tokens * {{cacheRatio}} / 1M tokens * ${{price}} + completion {{completion}} tokens / 1M tokens * ${{compPrice}} * group {{ratio}} = ${{total}}",
"缓存 Tokens": "Cache Tokens",
"系统初始化": "System initialization",
"管理员账号已经初始化过,请继续设置系统参数": "The admin account has already been initialized, please continue to set the system parameters",
"管理员账号已经初始化过,请继续设置其他参数": "The admin account has already been initialized, please continue to set other parameters",
"管理员账号": "Admin account",
"请输入管理员用户名": "Please enter the admin username",
"请输入管理员密码": "Please enter the admin password",
@@ -1457,7 +1457,7 @@
"管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。": "The administrator has not enabled the online recharge function, please contact the administrator to enable it or recharge with a redemption code.",
"点击模型名称可复制": "Click the model name to copy",
"管理您的邀请链接和收益": "Manage your invitation link and earnings",
"模型与邀请": "Model and Invitation",
"没有可用模型": "No available models",
"账户绑定": "Account Binding",
"安全设置": "Security Settings",
"系统访问令牌": "System Access Token",
@@ -1532,5 +1532,29 @@
"搜索条件": "Search Conditions",
"加载中...": "Loading...",
"暂无公告": "No Notice",
"操练场": "Playground"
"操练场": "Playground",
"欢迎使用,请完成以下设置以开始使用系统": "Welcome to use, please complete the following settings to start using the system",
"数据库信息": "Database Information",
"您正在使用 MySQL 数据库。MySQL 是一个可靠的关系型数据库管理系统,适合生产环境使用。": "You are using the MySQL database. MySQL is a reliable relational database management system, suitable for production environments.",
"您正在使用 PostgreSQL 数据库。PostgreSQL 是一个功能强大的开源关系型数据库系统,提供了出色的可靠性和数据完整性,适合生产环境使用。": "You are using the PostgreSQL database. PostgreSQL is a powerful open-source relational database system that provides excellent reliability and data integrity, suitable for production environments.",
"设置系统管理员的登录信息": "Set the login information for the system administrator",
"选择适合您使用场景的模式": "Select the mode suitable for your usage scenario",
"使用模式说明": "Usage mode description",
"计费模式": "Billing mode",
"多用户支持": "Multi-user support",
"个人使用": "Personal use",
"功能演示": "Function demonstration",
"体验试用": "Experience trial",
"默认模式": "Default Mode",
"无需计费": "No Charge",
"演示体验": "Demo Experience",
"提供基础功能演示,方便用户了解系统特性。": "Provide basic feature demonstrations to help users understand the system features.",
"适用于为多个用户提供服务的场景": "Suitable for scenarios where multiple users are provided.",
"适用于个人使用的场景,不需要设置模型价格": "Suitable for personal use, no need to set model price.",
"适用于展示系统功能的场景,提供基础功能演示": "Suitable for scenarios where the system functions are displayed, providing basic feature demonstrations.",
"账户数据": "Account Data",
"使用统计": "Usage Statistics",
"资源消耗": "Resource Consumption",
"性能指标": "Performance Indicators",
"模型数据分析": "Model Data Analysis"
}
+307 -152
View File
@@ -1,3 +1,4 @@
/* ==================== Tailwind CSS 配置 ==================== */
@layer tailwind-base, semi, tailwind-components, tailwind-utils;
@layer tailwind-base {
@@ -12,6 +13,7 @@
@tailwind utilities;
}
/* ==================== 全局基础样式 ==================== */
body {
margin: 0;
padding-top: 0;
@@ -25,80 +27,6 @@ body {
height: 100vh;
}
#root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li>span {
font-weight: 600 !important;
}
@media only screen and (max-width: 767px) {
#root>section>header>section>div>div>div>div.semi-navigation-footer>div>a>li {
padding: 0 0;
}
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li {
padding: 0 5px;
}
#root>section>header>section>div>div>div>div.semi-navigation-footer>div:nth-child(1)>a>li {
padding: 0 5px;
}
.semi-navigation-horizontal .semi-navigation-header {
margin-right: 0;
}
/* 确保移动端内容可滚动 */
.semi-layout-content {
-webkit-overflow-scrolling: touch !important;
overscroll-behavior-y: auto !important;
}
/* 修复移动端下拉刷新 */
body {
overflow: visible !important;
overscroll-behavior-y: auto !important;
position: static !important;
height: 100% !important;
}
/* 确保内容区域在移动端可以正常滚动 */
#root {
overflow: visible !important;
height: 100% !important;
}
.semi-table-tbody,
.semi-table-row,
.semi-table-row-cell {
display: block !important;
width: auto !important;
padding: 2px !important;
}
.semi-table-row-cell {
border-bottom: 0 !important;
}
.semi-table-tbody>.semi-table-row {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
}
.tableShow {
display: revert;
}
.tableHiddle {
display: none !important;
}
body::-webkit-scrollbar {
display: none;
}
@@ -108,10 +36,14 @@ code {
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.custom-footer {
font-size: 1.1em;
#root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ==================== 布局相关样式 ==================== */
.semi-layout-content::-webkit-scrollbar,
.semi-sider::-webkit-scrollbar {
width: 6px;
@@ -134,19 +66,161 @@ code {
background: transparent;
}
/* ==================== 导航和侧边栏样式 ==================== */
/* 导航项样式 */
.semi-navigation-sub-title,
.semi-chat-inputBox-sendButton,
.semi-page-item,
.semi-navigation-item,
.semi-tag-closable,
.semi-datepicker-range-input {
border-radius: 9999px !important;
}
.semi-navigation-item {
margin-bottom: 4px !important;
}
.semi-navigation-item-icon {
justify-items: center;
align-items: center;
}
.semi-navigation-item-icon-info {
margin-right: 0;
}
.semi-navigation-sub-title {
height: 100% !important;
}
.semi-navigation-item-collapsed {
height: 44px !important;
}
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li>span {
font-weight: 600 !important;
}
/* 自定义侧边栏样式 */
.sidebar-container {
height: 100%;
display: flex;
flex-direction: column;
transition: width 0.3s ease;
}
.sidebar-nav {
flex: 1;
width: 100%;
background: var(--semi-color-bg-0);
height: 100%;
overflow: hidden;
border-right: none;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
/* 侧边栏导航项样式 */
.sidebar-nav-item {
border-radius: 6px;
margin: 3px 8px;
transition: all 0.15s ease;
padding: 8px 12px;
}
.sidebar-nav-item:hover {
background-color: rgba(var(--semi-blue-0), 0.08);
color: var(--semi-color-primary);
}
.sidebar-nav-item-selected {
background-color: rgba(var(--semi-blue-0), 0.12);
color: var(--semi-color-primary);
font-weight: 500;
}
/* 图标容器样式 */
.sidebar-icon-container {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
transition: all 0.2s ease;
}
.sidebar-sub-icon-container {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
margin-left: 1px;
transition: all 0.2s ease;
}
/* 分割线样式 */
.sidebar-divider {
margin: 4px 8px;
opacity: 0.15;
}
/* 分组标签样式 */
.sidebar-group-label {
padding: 4px 15px 8px;
color: var(--semi-color-text-2);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.8;
}
/* 底部折叠按钮 */
.sidebar-collapse-button {
display: flex;
justify-content: center;
align-items: center;
padding: 12px;
cursor: pointer;
background-color: var(--semi-color-bg-0);
position: sticky;
bottom: 0;
z-index: 10;
box-shadow: 0 -10px 10px -5px var(--semi-color-bg-0);
backdrop-filter: blur(4px);
border-top: 1px solid rgba(var(--semi-grey-0), 0.08);
}
.sidebar-collapse-button-inner {
width: 28px;
height: 28px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--semi-color-fill-0);
transition: all 0.2s ease;
}
.semi-tabs-content {
padding: 0 !important;
.sidebar-collapse-icon-container {
display: inline-block;
transition: transform 0.3s ease;
}
/* 聊天 */
/* 侧边栏区域容器 */
.sidebar-section {
padding-top: 12px;
}
/* ==================== 聊天界面样式 ==================== */
.semi-chat {
padding-top: 0 !important;
padding-bottom: 0 !important;
@@ -183,13 +257,26 @@ code {
overflow-x: hidden !important;
}
.semi-chat-container {
overflow-x: hidden !important;
}
.semi-chat-chatBox-action {
column-gap: 0 !important;
}
.semi-chat-inputBox-clearButton.semi-button .semi-icon {
font-size: 20px !important;
}
/* 隐藏所有聊天相关区域的滚动条 */
.semi-chat::-webkit-scrollbar,
.semi-chat-chatBox::-webkit-scrollbar,
.semi-chat-chatBox-wrap::-webkit-scrollbar,
.semi-chat-chatBox-content::-webkit-scrollbar,
.semi-chat-content::-webkit-scrollbar,
.semi-chat-list::-webkit-scrollbar {
.semi-chat-list::-webkit-scrollbar,
.semi-chat-container::-webkit-scrollbar {
display: none;
}
@@ -198,40 +285,110 @@ code {
.semi-chat-chatBox-wrap,
.semi-chat-chatBox-content,
.semi-chat-content,
.semi-chat-list {
-ms-overflow-style: none;
scrollbar-width: none;
}
.semi-chat-container {
overflow-x: hidden !important;
}
.semi-chat-container::-webkit-scrollbar {
display: none;
}
.semi-chat-list,
.semi-chat-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ==================== 组件特定样式 ==================== */
/* Tabs组件样式 */
.semi-tabs-content {
padding: 0 !important;
height: calc(100% - 40px) !important;
flex: 1 !important;
}
.semi-tabs-content .semi-tabs-pane {
height: 100% !important;
overflow: hidden !important;
}
.semi-tabs-content .semi-tabs-pane>div {
height: 100% !important;
}
/* 表格样式 */
.tableShow {
display: revert;
}
.tableHiddle {
display: none !important;
}
/* 页脚样式 */
.custom-footer {
font-size: 1.1em;
}
/* ==================== 调试面板特定样式 ==================== */
.debug-panel .semi-tabs {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
.debug-panel .semi-tabs-bar {
flex-shrink: 0 !important;
}
.debug-panel .semi-tabs-content {
flex: 1 !important;
overflow: hidden !important;
}
/* ==================== 滚动条样式统一管理 ==================== */
/* 表格滚动条样式 */
.semi-table-body::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.semi-table-body::-webkit-scrollbar-thumb {
background: rgba(var(--semi-grey-2), 0.3);
border-radius: 2px;
}
.semi-table-body::-webkit-scrollbar-thumb:hover {
background: rgba(var(--semi-grey-2), 0.5);
}
.semi-table-body::-webkit-scrollbar-track {
background: transparent;
}
/* 侧边抽屉滚动条样式 */
.semi-sidesheet-body::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.semi-sidesheet-body::-webkit-scrollbar-thumb {
background: rgba(var(--semi-grey-2), 0.3);
border-radius: 2px;
}
.semi-sidesheet-body::-webkit-scrollbar-thumb:hover {
background: rgba(var(--semi-grey-2), 0.5);
}
.semi-sidesheet-body::-webkit-scrollbar-track {
background: transparent;
}
/* 隐藏模型设置区域的滚动条 */
.model-settings-scroll::-webkit-scrollbar {
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
.custom-request-textarea textarea::-webkit-scrollbar {
display: none;
}
.model-settings-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 思考内容区域滚动条样式 */
.thinking-content-scroll::-webkit-scrollbar {
display: none;
}
.thinking-content-scroll {
.model-settings-scroll,
.thinking-content-scroll,
.custom-request-textarea .semi-input,
.custom-request-textarea textarea {
-ms-overflow-style: none;
scrollbar-width: none;
}
@@ -255,60 +412,58 @@ code {
background: transparent;
}
/* 隐藏请求体 JSON TextArea 的滚动条 */
.custom-request-textarea .semi-input::-webkit-scrollbar {
display: none;
}
/* ==================== 响应式/移动端样式 ==================== */
@media only screen and (max-width: 767px) {
#root>section>header>section>div>div>div>div.semi-navigation-footer>div>a>li {
padding: 0 0;
}
.custom-request-textarea .semi-input {
-ms-overflow-style: none;
scrollbar-width: none;
}
#root>section>header>section>div>div>div>div.semi-navigation-header-list-outer>div.semi-navigation-list-wrapper>ul>div>a>li {
padding: 0 5px;
}
.custom-request-textarea textarea::-webkit-scrollbar {
display: none;
}
#root>section>header>section>div>div>div>div.semi-navigation-footer>div:nth-child(1)>a>li {
padding: 0 5px;
}
.custom-request-textarea textarea {
-ms-overflow-style: none;
scrollbar-width: none;
}
.semi-navigation-horizontal .semi-navigation-header {
margin-right: 0;
}
/* 调试面板标签样式 */
.semi-tabs-content {
height: calc(100% - 40px) !important;
flex: 1 !important;
}
/* 确保移动端内容可滚动 */
.semi-layout-content {
-webkit-overflow-scrolling: touch !important;
overscroll-behavior-y: auto !important;
}
.semi-tabs-content .semi-tabs-pane {
height: 100% !important;
overflow: hidden !important;
}
/* 修复移动端下拉刷新 */
body {
overflow: visible !important;
overscroll-behavior-y: auto !important;
position: static !important;
height: 100% !important;
}
.semi-tabs-content .semi-tabs-pane>div {
height: 100% !important;
}
/* 确保内容区域在移动端可以正常滚动 */
#root {
overflow: visible !important;
height: 100% !important;
}
/* 调试面板特定样式 */
.debug-panel .semi-tabs {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
/* 移动端表格样式调整 */
.semi-table-tbody,
.semi-table-row,
.semi-table-row-cell {
display: block !important;
width: auto !important;
padding: 2px !important;
}
.debug-panel .semi-tabs-bar {
flex-shrink: 0 !important;
}
.semi-table-row-cell {
border-bottom: 0 !important;
}
.debug-panel .semi-tabs-content {
flex: 1 !important;
overflow: hidden !important;
}
.semi-chat-chatBox-action {
column-gap: 0 !important;
}
.semi-chat-inputBox-clearButton.semi-button .semi-icon {
font-size: 20px !important;
.semi-table-tbody>.semi-table-row {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
}
+2 -1
View File
@@ -7,8 +7,9 @@ import { StatusProvider } from './context/Status';
import { Layout } from '@douyinfe/semi-ui';
import { ThemeProvider } from './context/Theme';
import { StyleProvider } from './context/Style/index.js';
import PageLayout from './components/PageLayout.js';
import PageLayout from './components/layout/PageLayout.js';
import './i18n/i18n.js';
import './index.css';
// initialization
+1 -1
View File
@@ -26,7 +26,7 @@ import {
Card,
Tag,
} from '@douyinfe/semi-ui';
import { getChannelModels } from '../../components/utils.js';
import { getChannelModels } from '../../helpers';
import {
IconSave,
IconClose,
+1 -1
View File
@@ -27,7 +27,7 @@ import {
IconUser,
IconCode,
} from '@douyinfe/semi-icons';
import { getChannelModels } from '../../components/utils.js';
import { getChannelModels } from '../../helpers';
import { useTranslation } from 'react-i18next';
const { Text, Title } = Typography;
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import ChannelsTable from '../../components/ChannelsTable';
import ChannelsTable from '../../components/table/ChannelsTable';
const File = () => {
return (
+1 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { useTokenKeys } from '../../components/fetchTokenKeys';
import { useTokenKeys } from '../../hooks/useTokenKeys';
import { Banner, Layout } from '@douyinfe/semi-ui';
import { useParams } from 'react-router-dom';
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { useTokenKeys } from '../../components/fetchTokenKeys';
import { useTokenKeys } from '../../hooks/useTokenKeys';
const chat2page = () => {
const { keys, chatLink, serverAddress, isLoading } = useTokenKeys();
+274 -96
View File
@@ -30,14 +30,12 @@ import {
showError,
timestamp2string,
timestamp2string1,
} from '../../helpers';
import {
getQuotaWithUnit,
modelColorMap,
renderNumber,
renderQuota,
modelToColor,
} from '../../helpers/render';
modelToColor
} from '../../helpers';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
@@ -210,6 +208,55 @@ const Detail = (props) => {
// 添加一个新的状态来存储模型-颜色映射
const [modelColors, setModelColors] = useState({});
// 添加趋势数据状态
const [trendData, setTrendData] = useState({
balance: [],
usedQuota: [],
requestCount: [],
times: [],
consumeQuota: [],
tokens: [],
rpm: [],
tpm: []
});
// 迷你趋势图配置
const getTrendSpec = (data, color) => ({
type: 'line',
data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
xField: 'x',
yField: 'y',
height: 40,
width: 100,
axes: [
{
orient: 'bottom',
visible: false
},
{
orient: 'left',
visible: false
}
],
padding: 0,
autoFit: false,
legends: { visible: false },
tooltip: { visible: false },
crosshair: { visible: false },
line: {
style: {
stroke: color,
lineWidth: 2
}
},
point: {
visible: false
},
background: {
fill: 'transparent'
}
});
// 显示搜索Modal
const showSearchModal = () => {
setSearchModalVisible(true);
@@ -284,12 +331,75 @@ const Detail = (props) => {
let uniqueModels = new Set();
let totalTokens = 0;
// 收集所有唯一的模型名称
// 趋势数据处理
let timePoints = [];
let timeQuotaMap = new Map();
let timeTokensMap = new Map();
let timeCountMap = new Map();
// 收集所有唯一的模型名称和时间点
data.forEach((item) => {
uniqueModels.add(item.model_name);
totalTokens += item.token_used;
totalQuota += item.quota;
totalTimes += item.count;
// 记录时间点
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
if (!timePoints.includes(timeKey)) {
timePoints.push(timeKey);
}
// 按时间点累加数据
if (!timeQuotaMap.has(timeKey)) {
timeQuotaMap.set(timeKey, 0);
timeTokensMap.set(timeKey, 0);
timeCountMap.set(timeKey, 0);
}
timeQuotaMap.set(timeKey, timeQuotaMap.get(timeKey) + item.quota);
timeTokensMap.set(timeKey, timeTokensMap.get(timeKey) + item.token_used);
timeCountMap.set(timeKey, timeCountMap.get(timeKey) + item.count);
});
// 确保时间点有序
timePoints.sort();
// 生成趋势数据
const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
// 计算RPM和TPM趋势
const rpmTrend = [];
const tpmTrend = [];
if (timePoints.length >= 2) {
const interval = dataExportDefaultTime === 'hour'
? 60 // 分钟/小时
: dataExportDefaultTime === 'day'
? 1440 // 分钟/天
: 10080; // 分钟/周
for (let i = 0; i < timePoints.length; i++) {
rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
}
}
// 更新趋势数据状态
setTrendData({
// 账户数据不在API返回中,保持空数组
balance: [],
usedQuota: [],
// 使用统计
requestCount: [], // 没有总请求次数趋势数据
times: countTrend,
// 资源消耗
consumeQuota: quotaTrend,
tokens: tokensTrend,
// 性能指标
rpm: rpmTrend,
tpm: tpmTrend
});
// 处理颜色映射
@@ -338,10 +448,10 @@ const Detail = (props) => {
}));
// 生成时间点序列
let timePoints = Array.from(
let chartTimePoints = Array.from(
new Set([...aggregatedData.values()].map((d) => d.time)),
);
if (timePoints.length < 7) {
if (chartTimePoints.length < 7) {
const lastTime = Math.max(...data.map((item) => item.created_at));
const interval =
dataExportDefaultTime === 'hour'
@@ -350,13 +460,13 @@ const Detail = (props) => {
? 86400
: 604800;
timePoints = Array.from({ length: 7 }, (_, i) =>
chartTimePoints = Array.from({ length: 7 }, (_, i) =>
timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
);
}
// 生成柱状图数据
timePoints.forEach((time) => {
chartTimePoints.forEach((time) => {
// 为每个时间点收集所有模型的数据
let timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
@@ -443,71 +553,103 @@ const Detail = (props) => {
}, []);
// 数据卡片信息
const statsData = [
const groupedStatsData = [
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked size="large" />,
title: t('账户数据'),
color: 'bg-blue-50',
avatarColor: 'blue',
onClick: () => navigate('/console/topup'),
items: [
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked size="large" />,
avatarColor: 'blue',
onClick: () => navigate('/console/topup'),
trendData: [], // 当前余额没有趋势数据
trendColor: '#3b82f6'
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram size="large" />,
avatarColor: 'purple',
trendData: [], // 历史消耗没有趋势数据
trendColor: '#8b5cf6'
}
]
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram size="large" />,
color: 'bg-purple-50',
avatarColor: 'purple',
},
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconRotate size="large" />,
title: t('使用统计'),
color: 'bg-green-50',
avatarColor: 'green',
items: [
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconRotate size="large" />,
avatarColor: 'green',
trendData: [], // 请求次数没有趋势数据
trendColor: '#10b981'
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse size="large" />,
avatarColor: 'cyan',
trendData: trendData.times,
trendColor: '#06b6d4'
}
]
},
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked size="large" />,
title: t('资源消耗'),
color: 'bg-yellow-50',
avatarColor: 'yellow',
items: [
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked size="large" />,
avatarColor: 'yellow',
trendData: trendData.consumeQuota,
trendColor: '#f59e0b'
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
icon: <IconTextStroked size="large" />,
avatarColor: 'pink',
trendData: trendData.tokens,
trendColor: '#ec4899'
}
]
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
icon: <IconTextStroked size="large" />,
color: 'bg-pink-50',
avatarColor: 'pink',
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse size="large" />,
color: 'bg-teal-50',
avatarColor: 'cyan',
},
{
title: t('平均RPM'),
value: (
times /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000)
).toFixed(3),
icon: <IconStopwatchStroked size="large" />,
title: t('性能指标'),
color: 'bg-indigo-50',
avatarColor: 'indigo',
},
{
title: t('平均TPM'),
value: (() => {
const tpm = consumeTokens /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000);
return isNaN(tpm) ? '0' : tpm.toFixed(3);
})(),
icon: <IconTypograph size="large" />,
color: 'bg-orange-50',
avatarColor: 'orange',
},
items: [
{
title: t('平均RPM'),
value: (
times /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000)
).toFixed(3),
icon: <IconStopwatchStroked size="large" />,
avatarColor: 'indigo',
trendData: trendData.rpm,
trendColor: '#6366f1'
},
{
title: t('平均TPM'),
value: (() => {
const tpm = consumeTokens /
((Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000);
return isNaN(tpm) ? '0' : tpm.toFixed(3);
})(),
icon: <IconTypograph size="large" />,
avatarColor: 'orange',
trendData: trendData.tpm,
trendColor: '#f97316'
}
]
}
];
// 获取问候语
@@ -614,48 +756,84 @@ const Detail = (props) => {
<Spin spinning={loading}>
<div className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{statsData.map((stat, idx) => (
{groupedStatsData.map((group, idx) => (
<Card
key={idx}
shadows='hover'
className={`${stat.color} border-0 !rounded-2xl w-full`}
headerLine={false}
onClick={stat.onClick}
shadows='always'
bordered={false}
className={`${group.color} border-0 !rounded-2xl w-full`}
headerLine={true}
header={<div style={{ color: 'white', fontWeight: 'bold', fontSize: '16px' }}>{group.title}</div>}
headerStyle={{
background: idx === 0
? 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)'
: idx === 1
? 'linear-gradient(135deg, #10b981 0%, #34d399 100%)'
: idx === 2
? 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%)'
: 'linear-gradient(135deg, #ec4899 0%, #f472b6 100%)',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px',
padding: '12px 16px',
}}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="medium"
color={stat.avatarColor}
>
{stat.icon}
</Avatar>
<div>
<div className="text-sm text-gray-500">{stat.title}</div>
<div className="text-xl font-semibold">{stat.value}</div>
</div>
<div className="space-y-4">
{group.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-center justify-between cursor-pointer"
onClick={item.onClick}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="small"
color={item.avatarColor}
>
{item.icon}
</Avatar>
<div>
<div className="text-xs text-gray-500">{item.title}</div>
<div className="text-lg font-semibold">{item.value}</div>
</div>
</div>
{item.trendData && item.trendData.length > 0 && (
<div className="w-24 h-10">
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={{ mode: 'desktop-browser' }}
/>
</div>
)}
</div>
))}
</div>
</Card>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型消耗分布')}>
<div style={{ height: 400 }}>
<VChart
spec={spec_line}
option={{ mode: 'desktop-browser' }}
/>
</div>
</Card>
<Card shadows='hover' className="shadow-sm !rounded-2xl" headerLine={true} title={t('模型调用次数占比')}>
<div style={{ height: 400 }}>
<VChart
spec={spec_pie}
option={{ mode: 'desktop-browser' }}
/>
<div className="grid grid-cols-1 lg:grid-cols-1 gap-6 mb-6">
<Card
shadows='always'
bordered={false}
className="shadow-sm !rounded-2xl"
headerLine={true}
title={t('模型数据分析')}
>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div style={{ height: 400 }}>
<VChart
spec={spec_line}
option={{ mode: 'desktop-browser' }}
/>
</div>
<div style={{ height: 400 }}>
<VChart
spec={spec_pie}
option={{ mode: 'desktop-browser' }}
/>
</div>
</div>
</Card>
</div>
+20 -8
View File
@@ -5,9 +5,9 @@ import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { IconGithubLogo } from '@douyinfe/semi-icons';
import exampleImage from '../../images/example.png';
import exampleImage from '/example.png';
import { Link } from 'react-router-dom';
import NoticeModal from '../../components/NoticeModal';
import NoticeModal from '../../components/layout/NoticeModal';
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
const { Text } = Typography;
@@ -22,11 +22,23 @@ const Home = () => {
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
useEffect(() => {
const lastCloseDate = localStorage.getItem('notice_close_date');
const today = new Date().toDateString();
if (lastCloseDate !== today) {
setNoticeVisible(true);
}
const checkNoticeAndShow = async () => {
const lastCloseDate = localStorage.getItem('notice_close_date');
const today = new Date().toDateString();
if (lastCloseDate !== today) {
try {
const res = await API.get('/api/notice');
const { success, data } = res.data;
if (success && data && data.trim() !== '') {
setNoticeVisible(true);
}
} catch (error) {
console.error('获取公告失败:', error);
}
}
};
checkNoticeAndShow();
}, []);
const displayHomePageContent = async () => {
@@ -79,7 +91,7 @@ const Home = () => {
<div className="flex-shrink-0 w-full md:w-[480px] md:mr-[60px] lg:mr-[120px] mb-8 md:mb-0">
<div className="flex items-center gap-2 justify-center md:justify-start">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-semibold text-semi-color-text-0 w-auto leading-normal md:leading-[67px]">
{statusState?.status?.system_name}
{statusState?.status?.system_name || 'New API'}
</h1>
{statusState?.status?.version && (
<Tag color='light-blue' size='large' shape='circle' className="ml-1">
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import LogsTable from '../../components/LogsTable';
import LogsTable from '../../components/table/LogsTable';
const Token = () => (
<>
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import MjLogsTable from '../../components/MjLogsTable';
import MjLogsTable from '../../components/table/MjLogsTable';
const Midjourney = () => (
<>
+5 -6
View File
@@ -7,9 +7,7 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
import { UserContext } from '../../context/User/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
// Utils and hooks
import { getLogo } from '../../helpers/index.js';
import { stringToColor } from '../../helpers/render.js';
// hooks
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
import { useMessageActions } from '../../hooks/useMessageActions.js';
import { useApiRequest } from '../../hooks/useApiRequest.js';
@@ -19,17 +17,18 @@ import { useDataLoader } from '../../hooks/useDataLoader.js';
// Constants and utils
import {
DEFAULT_MESSAGES,
MESSAGE_ROLES,
ERROR_MESSAGES
} from '../../utils/constants.js';
} from '../../constants/playground.constants.js';
import {
getLogo,
stringToColor,
buildMessageContent,
createMessage,
createLoadingAssistantMessage,
getTextContent,
buildApiPayload
} from '../../utils/messageUtils.js';
} from '../../helpers';
// Components
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import ModelPricing from '../../components/ModelPricing.js';
import ModelPricing from '../../components/table/ModelPricing.js';
const Pricing = () => (
<>
+2 -4
View File
@@ -6,11 +6,9 @@ import {
isMobile,
showError,
showSuccess,
} from '../../helpers';
import {
renderQuota,
renderQuotaWithPrompt,
} from '../../helpers/render';
renderQuotaWithPrompt
} from '../../helpers';
import {
AutoComplete,
Button,
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import RedemptionsTable from '../../components/RedemptionsTable';
import RedemptionsTable from '../../components/table/RedemptionsTable';
const Redemption = () => {
return (
@@ -17,8 +17,7 @@ import {
IconSave,
IconBolt,
} from '@douyinfe/semi-icons';
import { showError, showSuccess } from '../../../helpers';
import { API } from '../../../helpers';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function ModelRatioNotSetEditor(props) {
@@ -1,5 +1,5 @@
// ModelSettingsVisualEditor.js
import React, { useContext, useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
Table,
Button,
@@ -8,9 +8,7 @@ import {
Form,
Space,
RadioGroup,
Radio,
Tabs,
TabPane,
Radio
} from '@douyinfe/semi-ui';
import {
IconDelete,
@@ -19,11 +17,8 @@ import {
IconSave,
IconEdit,
} from '@douyinfe/semi-icons';
import { showError, showSuccess } from '../../../helpers';
import { API } from '../../../helpers';
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../../context/Status/index.js';
import { getQuotaPerUnit } from '../../../helpers/render.js';
export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
@@ -304,11 +299,11 @@ export default function ModelSettingsVisualEditor(props) {
prev.map((model, index) =>
index === existingModelIndex
? {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
}
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
}
: model,
),
);
@@ -456,8 +451,8 @@ export default function ModelSettingsVisualEditor(props) {
<Modal
title={
currentModel &&
currentModel.name &&
models.some((model) => model.name === currentModel.name)
currentModel.name &&
models.some((model) => model.name === currentModel.name)
? t('编辑模型')
: t('添加模型')
}
+6 -6
View File
@@ -3,13 +3,13 @@ import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import SystemSetting from '../../components/SystemSetting';
import SystemSetting from '../../components/settings/SystemSetting.js';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting';
import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
import RateLimitSetting from '../../components/RateLimitSetting.js';
import ModelSetting from '../../components/ModelSetting.js';
import OtherSetting from '../../components/settings/OtherSetting';
import PersonalSetting from '../../components/settings/PersonalSetting.js';
import OperationSetting from '../../components/settings/OperationSetting.js';
import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
import ModelSetting from '../../components/settings/ModelSetting.js';
const Setting = () => {
const { t } = useTranslation();
+433 -145
View File
@@ -6,14 +6,20 @@ import {
Typography,
Modal,
Banner,
Layout,
Tag,
} from '@douyinfe/semi-ui';
import { API, showError, showNotice } from '../../helpers';
import { useTranslation } from 'react-i18next';
import {
IconHelpCircle,
IconInfoCircle,
IconAlertTriangle,
IconUser,
IconLock,
IconSetting,
IconCheckCircleStroked,
} from '@douyinfe/semi-icons';
import { Shield, Rocket, FlaskConical, Database, Layers } from 'lucide-react';
const Setup = () => {
const { t } = useTranslation();
@@ -127,163 +133,445 @@ const Setup = () => {
};
return (
<>
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<Card>
<Title heading={2} style={{ marginBottom: '24px' }}>
{t('系统初始化')}
</Title>
{setupStatus.database_type === 'sqlite' && (
<Banner
type='warning'
icon={<IconAlertTriangle size='large' />}
closeIcon={null}
title={t('数据库警告')}
description={
<div>
<p>
{t(
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
)}
</p>
<p>
{t(
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
)}
</p>
</div>
}
style={{ marginBottom: '24px' }}
/>
)}
<Form
getFormApi={(formApi) => {
formRef.current = formApi;
console.log('Form API set:', formApi);
}}
initValues={formData}
>
{setupStatus.root_init ? (
<Banner
type='info'
icon={<IconInfoCircle />}
closeIcon={null}
description={t('管理员账号已经初始化过,请继续设置系统参数')}
style={{ marginBottom: '24px' }}
/>
) : (
<Form.Section text={t('管理员账号')}>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入管理员用户名')}
showClear
onChange={(value) =>
setFormData({ ...formData, username: value })
}
/>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入管理员密码')}
type='password'
showClear
onChange={(value) =>
setFormData({ ...formData, password: value })
}
/>
<Form.Input
field='confirmPassword'
label={t('确认密码')}
placeholder={t('请确认管理员密码')}
type='password'
showClear
onChange={(value) =>
setFormData({ ...formData, confirmPassword: value })
}
/>
</Form.Section>
)}
<Form.Section
text={
<div style={{ display: 'flex', alignItems: 'center' }}>
{t('系统设置')}
</div>
}
>
<Form.RadioGroup
field='usageMode'
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
{t('使用模式')}
<IconHelpCircle
style={{
marginLeft: '4px',
color: 'var(--semi-color-primary)',
verticalAlign: 'middle',
cursor: 'pointer',
}}
onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
setUsageModeInfoVisible(true);
}}
/>
<div className="min-h-screen bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center px-4 py-8">
<div className="w-full max-w-3xl">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部装饰性区域 */}
<Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden mb-6"
style={{
background: 'linear-gradient(135deg, #f97316 0%, #f59e0b 25%, #f43f5e 50%, #ec4899 75%, #e879f9 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-10 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-5 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
</div>
}
extraText={t('可在初始化后修改')}
initValue='external'
onChange={handleUsageModeChange}
>
<Form.Radio value='external'>{t('对外运营模式')}</Form.Radio>
<Form.Radio value='self'>{t('自用模式')}</Form.Radio>
<Form.Radio value='demo'>{t('演示站点模式')}</Form.Radio>
</Form.RadioGroup>
</Form.Section>
</Form>
<div style={{ marginTop: '24px', textAlign: 'right' }}>
<Button type='primary' onClick={onSubmit} loading={loading}>
{t('初始化系统')}
</Button>
<div className="relative py-5 px-6 flex items-center" style={{ color: 'white' }}>
<div className="w-14 h-14 rounded-full bg-white bg-opacity-20 flex items-center justify-center mr-5 shadow-lg flex-shrink-0">
<IconSetting size="large" style={{ color: 'white' }} />
</div>
<div className="text-left">
<Title heading={3} style={{ color: 'white', marginBottom: '2px' }}>
{t('系统初始化')}
</Title>
<Text style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: '15px' }}>
{t('欢迎使用,请完成以下设置以开始使用系统')}
</Text>
</div>
</div>
{/* 数据库警告 */}
{setupStatus.database_type === 'sqlite' && (
<div className="px-4">
<Banner
type='warning'
icon={
<div className="w-12 h-12 rounded-lg bg-orange-50 flex items-center justify-center">
<Database size={22} className="text-orange-500" />
</div>
}
closeIcon={null}
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库警告')}</span>
<Tag color='orange' size='small' className="ml-2 !rounded-full">
SQLite
</Tag>
</div>
}
description={
<div>
<p>
{t(
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
)}
</p>
<p className="mt-1">
<strong>{t(
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
)}</strong>
</p>
</div>
}
className="!rounded-xl mb-6"
fullMode={false}
bordered
/>
</div>
)}
{/* MySQL数据库提示 */}
{setupStatus.database_type === 'mysql' && (
<div className="px-4">
<Banner
type='info'
icon={
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center">
<Database size={22} className="text-blue-500" />
</div>
}
closeIcon={null}
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库信息')}</span>
<Tag color='blue' size='small' className="ml-2 !rounded-full">
MySQL
</Tag>
</div>
}
description={
<div>
<p>
{t(
'您正在使用 MySQL 数据库。MySQL 是一个可靠的关系型数据库管理系统,适合生产环境使用。',
)}
</p>
</div>
}
className="!rounded-xl mb-6"
fullMode={false}
bordered
/>
</div>
)}
{/* PostgreSQL数据库提示 */}
{setupStatus.database_type === 'postgres' && (
<div className="px-4">
<Banner
type='success'
icon={
<div className="w-12 h-12 rounded-lg bg-green-50 flex items-center justify-center">
<Database size={22} className="text-green-500" />
</div>
}
closeIcon={null}
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库信息')}</span>
<Tag color='green' size='small' className="ml-2 !rounded-full">
PostgreSQL
</Tag>
</div>
}
description={
<div>
<p>
{t(
'您正在使用 PostgreSQL 数据库。PostgreSQL 是一个功能强大的开源关系型数据库系统,提供了出色的可靠性和数据完整性,适合生产环境使用。',
)}
</p>
</div>
}
className="!rounded-xl mb-6"
fullMode={false}
bordered
/>
</div>
)}
</Card>
{/* 主内容区域 */}
<Form
getFormApi={(formApi) => {
formRef.current = formApi;
console.log('Form API set:', formApi);
}}
initValues={formData}
>
{/* 管理员账号设置 */}
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<IconUser size="large" style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('管理员账号')}</Text>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置系统管理员的登录信息')}</div>
</div>
</div>
{setupStatus.root_init ? (
<>
<Banner
type='info'
icon={
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center">
<IconCheckCircleStroked size="large" className="text-blue-500" />
</div>
}
closeIcon={null}
description={
<div className="flex items-center">
<span>{t('管理员账号已经初始化过,请继续设置其他参数')}</span>
</div>
}
className="!rounded-lg"
/>
</>
) : (
<>
<Form.Input
field='username'
label={t('用户名')}
placeholder={t('请输入管理员用户名')}
prefix={<IconUser />}
showClear
size='large'
className="mb-4 !rounded-lg"
noLabel={false}
validateStatus="default"
onChange={(value) =>
setFormData({ ...formData, username: value })
}
/>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入管理员密码')}
type='password'
prefix={<IconLock />}
showClear
size='large'
className="mb-4 !rounded-lg"
noLabel={false}
mode="password"
validateStatus="default"
onChange={(value) =>
setFormData({ ...formData, password: value })
}
/>
<Form.Input
field='confirmPassword'
label={t('确认密码')}
placeholder={t('请确认管理员密码')}
type='password'
prefix={<IconLock />}
showClear
size='large'
className="!rounded-lg"
noLabel={false}
mode="password"
validateStatus="default"
onChange={(value) =>
setFormData({ ...formData, confirmPassword: value })
}
/>
</>
)}
</Card>
{/* 使用模式 */}
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
<Layers size={22} style={{ color: '#ffffff' }} />
</div>
<div className="relative">
<div className="flex items-center">
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('使用模式')}</Text>
<Button
theme='borderless'
type='tertiary'
icon={<IconHelpCircle size="small" style={{ color: '#ffffff' }} />}
size='small'
onClick={() => setUsageModeInfoVisible(true)}
className="!rounded-full"
/>
</div>
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('选择适合您使用场景的模式')}</div>
</div>
</div>
<Form.RadioGroup
field='usageMode'
noLabel={true}
initValue='external'
onChange={handleUsageModeChange}
type='pureCard'
className="[&_.semi-radio-addon-buttonRadio-wrapper]:!rounded-xl"
validateStatus="default"
>
<div className="space-y-3 mt-2">
<Form.Radio
value='external'
className="!p-4 !rounded-xl hover:!bg-blue-50 transition-colors w-full"
extra={
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3 flex-shrink-0">
<Rocket size={20} className="text-blue-500" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('对外运营模式')}</div>
<div className="text-sm text-gray-500">{t('适用于为多个用户提供服务的场景')}</div>
<Tag color='blue' size='small' className="!rounded-full mt-2">
{t('默认模式')}
</Tag>
</div>
</div>
}
/>
<Form.Radio
value='self'
className="!p-4 !rounded-xl hover:!bg-green-50 transition-colors w-full"
extra={
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3 flex-shrink-0">
<Shield size={20} className="text-green-500" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('自用模式')}</div>
<div className="text-sm text-gray-500">{t('适用于个人使用的场景,不需要设置模型价格')}</div>
<Tag color='green' size='small' className="!rounded-full mt-2">
{t('无需计费')}
</Tag>
</div>
</div>
}
/>
<Form.Radio
value='demo'
className="!p-4 !rounded-xl hover:!bg-purple-50 transition-colors w-full"
extra={
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3 flex-shrink-0">
<FlaskConical size={20} className="text-purple-500" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('演示站点模式')}</div>
<div className="text-sm text-gray-500">{t('适用于展示系统功能的场景,提供基础功能演示')}</div>
<Tag color='purple' size='small' className="!rounded-full mt-2">
{t('演示体验')}
</Tag>
</div>
</div>
}
/>
</div>
</Form.RadioGroup>
</Card>
</Form>
<div className="flex justify-center mt-6">
<Button
type='primary'
onClick={onSubmit}
loading={loading}
size='large'
className="!rounded-lg !bg-gradient-to-r !from-orange-500 !to-pink-500 hover:!from-orange-600 hover:!to-pink-600 !border-0 !px-8"
icon={<IconCheckCircleStroked />}
>
{t('初始化系统')}
</Button>
</div>
</Card>
</div>
</div>
</Card>
</div>
</Layout.Content>
</Layout>
{/* 使用模式说明模态框 */}
<Modal
title={t('使用模式说明')}
title={
<div className="flex items-center">
<IconInfoCircle className="mr-2 text-blue-500" />
{t('使用模式说明')}
</div>
}
visible={selfUseModeInfoVisible}
onOk={() => setUsageModeInfoVisible(false)}
onCancel={() => setUsageModeInfoVisible(false)}
closeOnEsc={true}
okText={t('确定')}
okText={t('我已了解')}
cancelText={null}
centered={true}
size='medium'
className="[&_.semi-modal-body]:!p-6"
>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('对外运营模式')}</Title>
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
<p>
{t(
'此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。',
)}
</p>
</div>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('自用模式')}</Title>
<p>{t('适用于个人使用的场景。')}</p>
<p>
{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}
</p>
</div>
<div style={{ padding: '8px 0' }}>
<Title heading={6}>{t('演示站点模式')}</Title>
<p>{t('适用于展示系统功能的场景。')}</p>
<div className="space-y-6">
{/* 对外运营模式 */}
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center mr-3 flex-shrink-0">
<Rocket size={20} className="text-blue-600" />
</div>
<div>
<Title heading={6} className="text-blue-900 mb-2">{t('对外运营模式')}</Title>
<div className="space-y-2 text-sm text-gray-700">
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
<p>{t('此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。')}</p>
<div className="mt-3">
<Tag color='blue' className="!rounded-full mr-2">{t('计费模式')}</Tag>
<Tag color='blue' className="!rounded-full">{t('多用户支持')}</Tag>
</div>
</div>
</div>
</div>
</div>
{/* 自用模式 */}
<div className="bg-green-50 rounded-xl p-4">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center mr-3 flex-shrink-0">
<Shield size={20} className="text-green-600" />
</div>
<div>
<Title heading={6} className="text-green-900 mb-2">{t('自用模式')}</Title>
<div className="space-y-2 text-sm text-gray-700">
<p>{t('适用于个人使用的场景。')}</p>
<p>{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}</p>
<div className="mt-3">
<Tag color='green' className="!rounded-full mr-2">{t('无需计费')}</Tag>
<Tag color='green' className="!rounded-full">{t('个人使用')}</Tag>
</div>
</div>
</div>
</div>
</div>
{/* 演示站点模式 */}
<div className="bg-purple-50 rounded-xl p-4">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center mr-3 flex-shrink-0">
<FlaskConical size={20} className="text-purple-600" />
</div>
<div>
<Title heading={6} className="text-purple-900 mb-2">{t('演示站点模式')}</Title>
<div className="space-y-2 text-sm text-gray-700">
<p>{t('适用于展示系统功能的场景。')}</p>
<p>{t('提供基础功能演示,方便用户了解系统特性。')}</p>
<div className="mt-3">
<Tag color='purple' className="!rounded-full mr-2">{t('功能演示')}</Tag>
<Tag color='purple' className="!rounded-full">{t('体验试用')}</Tag>
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
</>
</div>
);
};
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import TaskLogsTable from '../../components/TaskLogsTable.js';
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
const Task = () => (
<>
+2 -1
View File
@@ -6,8 +6,9 @@ import {
showError,
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt
} from '../../helpers';
import { renderGroupOption, renderQuotaWithPrompt } from '../../helpers/render';
import {
AutoComplete,
Banner,

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