mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
+3








a42b397607
* feat: add parameter coverage for the operations: copy, trim_prefix, trim_suffix, ensure_prefix, ensure_suffix, trim_space, to_lower, to_upper, replace, and regex_replace * fix: CrossGroupRetry default false 移除gorm:"default:false",避免每次 AutoMigrate时都执行ALTER TABLE `tokens` MODIFY COLUMN `cross_group_retry` boolean DEFAULT false 且bool默认false不影响原有功能 * feat: check-in feature integrates Turnstile security check * feat: add support for Doubao /v1/responses (#2567) * feat: add support for Doubao /v1/responses * fix: fix model deployment style issues, lint problems, and i18n gaps. (#2556) * fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined. * feat: add plans directory to .gitignore * fix: 修复 gemini 文件类型不支持 image/jpg * fix: fix the proxyURL is empty, not using the default HTTP client configuration && the AWS calling side did not apply the relay timeout. * fix: batch add key backend deduplication * Merge pull request #2582 from seefs001/fix/tips fix: add tips for model management and channel testing * fix(gin): update request body size check to allow zero limit * feat: add regex pattern to mask API keys in sensitive information * fix(task): 修复使用 auto 分组时 Task Relay 不记录日志和不扣费的问题 问题描述: - 使用 auto 分组的令牌调用 /v1/videos 等 Task 接口时,虽然任务能成功创建, 但使用日志不显示记录,且不会扣费 根本原因: - Distribute 中间件在选择渠道后,会将实际选中的分组存储在 ContextKeyAutoGroup 中 - 但 RelayTaskSubmit 函数没有从 context 中读取这个值来更新 info.UsingGroup - 导致 info.UsingGroup 始终是 "auto" 而不是实际选中的分组(如 "sora2逆") - 当 auto 分组的倍率配置为 0 时,quota 计算结果为 0 - 日志记录条件 "if quota != 0" 不满足,导致日志不记录、不扣费 修复方案: - 在 RelayTaskSubmit 函数中计算分组倍率之前,添加从 ContextKeyAutoGroup 获取实际分组的逻辑 - 使用安全的类型断言,避免潜在的 panic 风险 影响范围: - 仅影响 Task Relay 流程(/v1/videos, /suno, /kling 等接口) - 不影响使用具体分组令牌的调用 - 不影响其他 Relay 类型(chat/completions 等已有类似处理逻辑) * 🚀 feat(web): port legacy v2 frontend changes into new UI (deployments, check-in, ollama) + align APIs Bring over the key frontend functionality introduced in merge `efa3301` and integrate it cleanly into the new `web/src` architecture and design system. - **Model deployments (io.net)** - Align frontend endpoints and payloads with backend deployment routes (`/api/deployments/*`) - Add missing deployment operations: details, logs (container-aware), update config, rename, extend duration - Improve create-deployment flow (proper request shape, name availability check, price estimation parity) - **System settings** - Enhance io.net deployment settings: allow testing connection with an unsaved API key and add “how to get API key” guidance - **Channels / Ollama** - Improve Ollama model management: live fetch via base_url with fallback to channel fetch, selection + apply flows, delete confirmation - Refactor for feature-layer consistency: extract Ollama parsing/normalization utilities into `features/channels/lib` - **Quality** - Ensure TypeScript typecheck passes after refactor and new dialogs/components integration * Merge pull request #2590 from xyfacai/fix/max-body-limit fix: 设置默认max req body 为128MB * docs: update readme * i18n: add missing translations * fix(gemini): fetch model list via native v1beta/models endpoint Use the native Gemini Models API (/v1beta/models) instead of the OpenAI-compatible path when listing models for Gemini channels, improving compatibility with third-party Gemini-format providers that don't implement OpenAI routes. - Add paginated model listing with timeout and optional proxy support - Select an enabled key for multi-key Gemini channels * refactor(gemini): 更新 GeminiModelsResponse 以使用 dto.GeminiModel 类型 * fix: remove Minimax from FETCHABLE channels * fix(minimax): 添加 MiniMax-M2 系列模型到 ModelList * feat: add doubao video 1.5 * 🤢 chore: remove useless file * feat: /v1/chat/completion -> /v1/response (#2629) * feat: /v1/chat/completion -> /v1/response * fix: clean propertyNames for gemini function * fix: support snake_case fields in GeminiChatGenerationConfig * chore: update dependencies and lockfile for improved compatibility - Updated @clerk/clerk-react to version 5.59.3 - Updated @hookform/resolvers to version 5.2.2 - Updated @lobehub/icons to version 2.48.0 - Updated various Radix UI components to their latest versions - Updated @tanstack/react-query and related packages for better performance - Updated axios, i18next, and other libraries for security and feature enhancements - Updated lockfile to include configVersion and ensure consistency across environments * Merge pull request #2647 from seefs001/feature/status-code-auto-disable feat: status code auto-disable configuration * fix: chat2response setting ui (#2643) * fix: setting ui * fix: rm global.chat_completions_to_responses_policy * fix: rm global.chat_completions_to_responses_policy * Merge pull request #2627 from seefs001/feature/channel-test-param-override feat: channel testing supports parameter overriding * chore: update dependencies and lockfile for improved compatibility - Updated @lobehub/icons to version 4.0.3 - Updated ai to version 6.0.27 - Updated various libraries including axios, react-day-picker, and streamdown for security and feature enhancements - Updated devDependencies for eslint, prettier, and typescript for better performance and compatibility - Updated lockfile to ensure consistency across environments * chore: update lockfile and Vite configuration for improved build process - Updated lockfile to version 1 for better compatibility and consistency - Enhanced Vite configuration to support production optimizations, including code minification and chunking for dependencies - Added environment-specific console and debugger removal for production builds * chore: migrate from Vite to Rsbuild for build process - Added Rsbuild configuration for development and production builds - Updated package.json scripts to use Rsbuild instead of Vite - Replaced @tailwindcss/vite with @tailwindcss/postcss in dependencies - Introduced postcss.config.mjs for Tailwind CSS integration - Updated TypeScript configuration to include Rsbuild config - Removed Vite configuration file to streamline the build process * refactor: optimize user data handling and API calls - Replaced direct API calls to get user data with cached user information from auth-store in ModelsFilter and SummaryCards components. - Improved session management in RootComponent and Authenticated route to utilize localStorage for user authentication status, reducing unnecessary API requests. - Added caching for setup status checks to enhance performance during navigation. * feat: enhance session validation in authenticated route - Implemented session verification to check user authentication status via API call only once per session. - Updated beforeLoad logic to redirect users to the login page if session validation fails or if no user information is available in localStorage. - Improved user data handling by updating the auth store with fresh user information upon successful session verification. * refactor: improve useMediaQuery hook for better SSR handling - Enhanced the useMediaQuery hook to check for window availability before accessing matchMedia, preventing errors during server-side rendering. - Simplified state initialization and change handling by using a dedicated function to determine initial matches. - Updated event listener management for improved performance and clarity. * feat(hooks): export useMediaQuery from hooks index * refactor: update useMediaQuery imports to use unified hooks index * fix(rsbuild): fix loadEnv API usage and removeConsole type * feat: customizable automatic retry status codes * refactor(hooks): use useSyncExternalStore for better SSR handling in useMediaQuery * refactor: simplify embedded file structure in main.go - Updated the embedded file directive to include the entire web/dist directory instead of individual assets, streamlining the build process. * refactor: replace DropdownMenu with Sheet component in ProfileDropdown - Updated the ProfileDropdown component to use a Sheet for user interactions instead of a DropdownMenu. - Enhanced user info display with improved layout and styling. - Added navigation links and sign-out functionality within the Sheet. * refactor: streamline ProfileDropdown layout and improve user info display - Removed unused Badge component and secondary text from user display. - Enhanced styling for user info section and navigation links. - Updated sign-out functionality to use a button for better accessibility. * feat: add System Settings link for super admin in ProfileDropdown - Introduced a new link to System Settings in the ProfileDropdown, visible only to users with the SUPER_ADMIN role. - Updated imports to include the Settings icon and adjusted the component logic accordingly. - Removed the Settings entry from the sidebar data to streamline navigation. * feat: codex channel (#2652) * feat: codex channel * feat: codex channel * feat: codex oauth flow * feat: codex refresh cred * feat: codex usage * fix: codex err message detail * fix: codex setting ui * feat: codex refresh cred task * fix: import err * fix: codex store must be false * fix: chat -> responses tool call * fix: chat -> responses tool call * feat(i18n): add missing translations * fix(i18n): restore missing translations for "360" and add "User Menu" in multiple locales - Reintroduced the translation for "360" in English, French, Japanese, Russian, Vietnamese, and Chinese locales. - Added the "User Menu" translation in the same languages to enhance user interface consistency. * fix: openAI function to gemini function field adjusted to whitelist mode * feat: TLS_INSECURE_SKIP_VERIFY env * fix: for chat-based calls to the Claude model, tagging is required. Using Claude's rendering logs, the two approaches handle input rendering differently. * refactor(system-settings): restructure settings sections and navigation - Replaced SettingsAccordion with a unified SettingsSection component across various settings sections for consistency. - Introduced a section registry to manage general settings sections dynamically. - Updated navigation items in the system settings sidebar to utilize the new section registry. - Enhanced the GeneralSettings component to support section-based content rendering based on user selection. * fix(system-settings): remove type assertion for quotaDisplayType in GeneralSettings - Eliminated the type assertion for quotaDisplayType in the GeneralSettings component to improve type inference and maintain cleaner code. * refactor(system-settings): update zod import syntax in general settings - Changed the import statement for zod from a default import to a namespace import for better clarity and consistency in the codebase. * fix: the login method cannot be displayed under the aff link. * feat(system-settings): implement generic settings page and enhance navigation - Added a new generic SettingsPage component to handle loading states, data fetching, and section rendering. - Integrated section registry for general and authentication settings to streamline navigation and content management. - Updated URL utility functions to improve query parameter handling for active navigation states. - Enhanced the system settings sidebar to include authentication section items dynamically. * refactor(system-settings): replace SettingsAccordion with SettingsSection across authentication settings - Updated BasicAuthSection, BotProtectionSection, OAuthSection, and PasskeySection to use the new SettingsSection component for consistency. - Introduced a section registry to manage authentication settings dynamically, enhancing navigation and content rendering. * feat(system-settings): enhance request limits settings with new section and unified component - Added a new Request Limits section to the system settings sidebar, integrating it with the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in RateLimitSection, SensitiveWordsSection, and SSRFSection for consistency. - Updated RequestLimitsSettings to utilize the new SettingsPage component for better data handling and rendering. - Implemented a search schema for request limits to streamline navigation and section management. * feat(system-settings): integrate content settings sections with unified component and registry - Added a new Content section to the system settings sidebar, incorporating it into the section registry for improved navigation. - Replaced SettingsAccordion with SettingsSection in multiple content-related components for consistency. - Created a section registry to manage content settings dynamically, enhancing the rendering and navigation experience. - Updated the ContentSettings component to utilize the new section registry and streamline content display. * feat(system-settings): enhance integrations settings with unified section registry and components - Introduced a new section registry for integrations settings, consolidating various settings components for better organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple integration-related components for consistency. - Updated IntegrationSettings to utilize the new SettingsPage component, improving data handling and rendering. - Added a new integrations section to the system settings sidebar, enhancing user experience and accessibility. * feat(system-settings): unify model settings with new section registry and components - Introduced a section registry for model settings, consolidating various model-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple model settings components for consistency. - Updated ModelSettings to utilize the new SettingsPage component, enhancing data handling and rendering. - Added a new Models section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): enhance maintenance settings with unified section registry and components - Introduced a new section registry for maintenance settings, consolidating various maintenance-related components for improved organization and navigation. - Replaced SettingsAccordion with SettingsSection in multiple maintenance components for consistency. - Updated MaintenanceSettings to utilize the new section registry, enhancing data handling and rendering. - Added a new Maintenance section to the system settings sidebar, improving user experience and accessibility. * feat(system-settings): update section titles for improved clarity and consistency - Renamed various section titles across content, integrations, maintenance, models, and request limits to enhance clarity and better reflect their functionalities. - Adjusted titles such as 'Dashboard' to 'Data Dashboard', 'API Info' to 'API Addresses', and 'Update Checker' to 'System maintenance' for improved user understanding. - Ensured consistency in naming conventions across all settings sections to streamline user experience and navigation. * feat(nav-group): enhance collapsible menu behavior and URL matching logic - Added controlled state management for collapsible menu items to automatically expand based on active sub-item paths. - Updated the URL matching logic in checkIsActive to improve handling of query parameters and ensure accurate navigation state detection. - Refactored the collapsible component to utilize the new state management, enhancing user experience in the sidebar navigation. * feat(system-settings): update system settings navigation and redirect logic - Changed the link in the profile dropdown to point directly to the general section of system settings with a search parameter for section identification. - Implemented a redirect in the general settings route to ensure users are directed to the default section if no section parameter is provided, enhancing navigation consistency. * feat(system-settings): unify route configuration for settings sections - Refactored route configuration for various system settings sections (auth, content, general, integrations, maintenance, models, request limits) to utilize a new `createSettingsRouteConfig` function. - This change consolidates the repetitive logic of creating search schemas and handling redirects, improving code maintainability and readability. - Enhanced navigation by ensuring default sections are loaded when no section parameter is provided. * feat(url-utils): enhance URL handling and matching logic - Introduced a new utility function `urlToString` to convert various URL formats (string and object) into a standardized string format. - Updated the `checkIsActive` function to utilize `urlToString`, improving the accuracy of URL matching and handling of query parameters. - Refactored URL comparison logic to ensure consistent behavior across different URL types, enhancing navigation state detection. * feat(system-settings): validate DataExportDefaultTime for improved data handling - Introduced a new function `validateDataExportDefaultTime` to ensure the `DataExportDefaultTime` value is either 'week', 'hour', or 'day', defaulting to 'hour' for unexpected values. - Updated the `DataExportDefaultTime` assignment in the settings section to utilize this validation function, enhancing data integrity and user experience. * perf(system-settings): Improve the i18n of system settings content - Changed button labels in various sections to use consistent capitalization and translation functions, enhancing user experience. - Updated validation messages in schemas to utilize translation functions for improved internationalization support. - Ensured all user-facing strings are properly translated, improving accessibility for non-English users. * fix(system-settings): update ApiInfoFormValues type inference for improved schema validation - Changed the type inference for ApiInfoFormValues to utilize ReturnType of createApiInfoSchema, ensuring accurate type representation and enhancing type safety in the API info section. * fix(chat-settings): improve validation logic for chat settings schema - Updated the validation logic to ensure that null values are correctly handled and that only objects are accepted as valid items in the chat settings schema. - Simplified error handling by removing the error message from the catch block, providing a consistent user-facing message for invalid JSON strings. * fix(system-settings): enhance validation error handling in uptime-kuma schema - Updated the validation logic for category name, URL, and slug fields to use an object format for error messages, improving clarity and consistency in user feedback. - Ensured that all validation messages are properly structured to enhance internationalization support. * fix(i18n): add translations for Uptime Kuma group management - Added English, French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - Included validation messages for category name and slug fields across multiple languages to improve user feedback and accessibility. * fix(system-settings): improve validation error message structure for SystemName - Updated the validation logic for the SystemName field to use an object format for error messages, enhancing clarity and consistency in user feedback. - This change aligns with recent improvements in internationalization support across the system settings schemas. * perf(i18n): add new validation error message translations - Added translations for the new validation error message "Invalid JSON format or values out of allowed range" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances internationalization support by ensuring users receive clear feedback across multiple languages. * fix(i18n): update Japanese translation for payment method configuration message - Corrected the Japanese translation for the message regarding payment methods configuration to use the term "メソッド" instead of "方法" for improved accuracy and consistency in user feedback. - This change enhances the clarity of the message for Japanese-speaking users. * fix(i18n): remove unnecessary loading messages from French translations - Removed the French translations for "Loading settings...", "Loading maintenance settings...", and "Loading content settings..." to streamline the localization file. - This change improves the clarity and relevance of the translations provided to users. * fix(i18n): add translations for Uptime Kuma group management in multiple languages - Added French, Japanese, Russian, Vietnamese, and Chinese translations for "Add Uptime Kuma Group" and "Edit Uptime Kuma Group" to enhance internationalization support. - This update improves user experience by providing clear and consistent messaging across different languages. * fix(validation): enhance pricing schema error messages and add translations - Updated the pricing schema to include localized error messages for validation, ensuring users receive clear feedback when input values are invalid. - Added new translations for "Exchange rate is required" and "Exchange rate must be greater than 0" in English, French, Japanese, and Chinese to improve internationalization support. - This change enhances user experience by providing accurate and contextually relevant messages across multiple languages. * fix: codex Unsupported parameter: max_output_tokens * fix(model-mapping-editor): simplify JSON parsing logic in useEffect * fix: jimeng i2v support multi image by metadata * refactor(models): restructure models section handling and improve UI components - Replaced tab-based navigation with section-based navigation for better clarity and organization. - Introduced a new section registry to manage model sections, including 'metadata' and 'deployments'. - Updated the ModelsContent component to reflect the new section structure and added a Create Deployment button. - Removed the ModelsTabs component as it was no longer needed. - Enhanced internationalization support by adding new translations for section descriptions and management tasks. - Adjusted sidebar configuration to accommodate the new section structure. * fix: update warning threshold label from '5$' to '2$' * fix: video content api Priority use url field * fix: update abortWithOpenAiMessage function to use types.ErrorCode * feat(deployment): introduce CreateDeploymentDrawer component and update dialog references - Replaced the CreateDeploymentDialog with a new CreateDeploymentDrawer component for improved user experience. - Added comprehensive form handling for deployment creation, including validation and price estimation features. - Updated internationalization files to include new translations for UI elements and descriptions related to deployment configuration. - Enhanced the ModelsContent component to integrate the new drawer for creating deployments. * perf(i18n): enhance internationalization for models table and columns - Updated labels and titles in the ModelsTable and useModelsColumns components to utilize translation functions for improved localization. - Changed static text for vendor and sync status to dynamic translations, enhancing user experience for non-English speakers. - Updated empty state messages in the ModelsTable to support internationalization, ensuring clarity for all users. * fix: fix email send * fix: issue where consecutive calls to multiple tools in gemini all returned an index of 0 * fix: replace Alibaba's Claude-compatible interface with the new interface * fix: Only models with the "qwen" designation can use the Claude-compatible interface; others require conversion. * feat: log shows request conversion * feat: optimized display * feat: optimized display * feat: optimized display * fix: codex rm Temperature * Revert "fix: video content api Priority use url field" * feat: requestId time string use UTC * feat(qwen): support qwen image sync image model config * feat: sync old ui * feat: more ui sync * feat: replace theme * fix build * refactor(web): revert theme colors and variables in CSS Updated color variables for light and dark themes to improve consistency and visual appeal. * feat(deployment): enhance deployment access guard and model deployment settings - Introduced loading phase management in the DeploymentAccessGuard component to provide better user feedback during connection checks. - Updated the ModelsContent component to prefetch the deployments list while checking connection status, improving data readiness. - Implemented a caching mechanism for connection status in useModelDeploymentSettings to optimize performance and reduce unnecessary API calls. - Enhanced loading states and error handling for improved user experience during deployment settings retrieval and connection testing. * feat(i18n): add new translations for connection and loading states across multiple languages - Introduced translations for "Checking connection" and "Loading configuration" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update enhances the internationalization support, providing clearer user feedback during connection checks and loading phases. * refactor(pagination): adjust layout and styling for pagination component - Updated the pagination component to improve layout by removing unnecessary width constraints and enhancing responsiveness. - Increased minimum width for pagination text to ensure better visibility and alignment across different screen sizes. * feat(i18n): implement translations for various UI elements across multiple components - Updated several components to utilize the translation function for titles and placeholders, enhancing internationalization support. - Added new translation entries for "Filter by name or key..." and "Log Type" in English, French, Japanese, Russian, Vietnamese, and Chinese. - This update improves user experience by providing localized text in the ChannelsTable, SummaryCards, ApiKeysTable, RedemptionsTable, UsageLogsTable, and UsersTable components. * feat(i18n): integrate translation support in SummaryCards component - Added the useTranslation hook to the SummaryCards component to enhance internationalization. - This update allows for localized text rendering, improving user experience for diverse language speakers. * feat(dashboard): refactor dashboard structure and introduce section-based navigation - Removed the tab navigation in favor of a section-based approach, enhancing user experience by providing clearer context for the dashboard content. - Introduced a new section registry to manage dashboard sections, allowing for easier expansion and maintenance. - Updated sidebar configuration to reflect the new section structure, ensuring proper navigation links are displayed. - Added translations for new section titles and descriptions to support internationalization. * feat(i18n): update time range labels and enhance translation support - Changed time range labels from shorthand (e.g., '1D') to full text (e.g., '1 Day') for better clarity. - Updated various components to utilize the translation function for time range labels, improving internationalization. - Added new translation entries for time ranges in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience across languages. * feat(dashboard): enhance type safety and improve component structure - Updated the Dashboard component to use specific types for model data and filters, enhancing type safety. - Introduced new types for announcements and FAQs, improving clarity and maintainability. - Refactored LogStatCards and UptimePanel components to utilize AbortController for better data fetching management. - Optimized the rendering of announcements and FAQs by using unique keys based on item IDs. - Improved theme management in ModelCharts by caching the ThemeManager import to reduce dynamic imports. * feat(agents): add comprehensive guidelines for React and Next.js development - Introduced a new set of best practices and optimization techniques for React and Next.js applications, aimed at enhancing performance and maintainability. - Included detailed rules covering various aspects such as event handling, API routes, rendering strategies, and state management. - Added extensive documentation in AGENTS.md and SKILL.md to support developers in adhering to these practices. - This update serves as a foundational resource for improving code quality and efficiency in React-based projects. * chore(web): update package.json dependencies - Removed outdated dependencies including @base-ui/react, @clerk/clerk-react, and others to streamline the project. - Updated remaining dependencies to their latest versions for improved performance and security. - This cleanup enhances the overall maintainability of the project. * feat(usage-logs): implement section-based navigation and enhance log management - Introduced a section registry for usage logs, allowing for better organization and navigation between different log categories (common, drawing, task). - Updated the UsageLogsContent component to dynamically render titles and descriptions based on the selected section. - Refactored UsageLogsTable and UsageLogsPrimaryButtons components to accept the active log category as a prop, improving modularity. - Enhanced sidebar configuration to support new section navigation, ensuring users can easily access different log types. - Updated routing to redirect to the default section if none is specified, improving user experience. * feat(i18n): enhance internationalization across usage logs components - Integrated the useTranslation hook in various components related to usage logs, including CommonLogsStats, UsageLogsTable, and column helpers. - Updated labels, titles, and messages to utilize translation functions, improving localization support. - Added new translation entries for log-related terms in English, French, Japanese, Russian, Vietnamese, and Chinese, enhancing user experience for diverse language speakers. * feat(datetime-picker): integrate dayjs for date formatting - Added dayjs as a dependency to the project for improved date handling. - Updated the DateTimePicker component to use dayjs for formatting dates, enhancing consistency and readability of date displays. * feat(date-handling): replace date-fns with dayjs for improved date management - Updated the project to use dayjs instead of date-fns for date formatting and manipulation, enhancing consistency across components. - Refactored DatePicker, DateTimePicker, and other components to utilize dayjs for date-related functionalities. - Added a new dayjs configuration file to extend its capabilities with relative time support. - Updated AGENTS.md to reflect the new technology stack, emphasizing the use of dayjs for date handling. * refactor(agents): streamline front-end development guidelines and update technology stack - Revised AGENTS.md to condense front-end development standards and best practices, making it more accessible for developers and AI assistants. - Updated the technology stack section to reflect current dependencies, emphasizing the use of Bun, React 19, TypeScript, and other key libraries. - Enhanced the document structure with a new table format for better readability and navigation, including a comprehensive table of contents for quick access to sections. * feat(i18n): enhance date picker and datetime picker localization support - Integrated internationalization support in DatePicker and DateTimePicker components by adding locale handling for multiple languages (English, French, Japanese, Russian, Vietnamese, Chinese). - Updated the calendar component to accept a locale prop, ensuring proper localization of month and weekday labels. - Improved user experience by allowing date selection to adapt based on the user's language preference. * feat(layout): add SectionPageLayout component for structured page layouts - Introduced a new SectionPageLayout component to facilitate structured layouts for pages with sections, enhancing the organization of content. - Added subcomponents for Title, Description, Actions, and Content to improve clarity and maintainability of page structures. - Updated AGENTS.md to include guidelines on avoiding unnecessary destructuring of props for better code readability. * feat(layout): refactor components to use SectionPageLayout for improved structure - Replaced AppHeader and Main components with SectionPageLayout across multiple features including Channels, Dashboard, ApiKeys, Models, Redemption Codes, Usage Logs, Users, and Wallet. - Enhanced page organization by utilizing SectionPageLayout's Title, Description, Actions, and Content subcomponents, improving clarity and maintainability. - This update standardizes the layout structure across the application, facilitating a more cohesive user experience. * feat(usage-logs): enhance URL state management and redirection logic - Added useEffect to synchronize column filters with URL search changes, preventing infinite loops caused by inline references. - Improved redirection logic in usage logs to clear 'type' from the URL when the section is not 'common', enhancing user experience and URL cleanliness. * fix(usage-logs): disable global filter and update DataTableToolbar props - Disabled the global filter in the UsageLogsTable component to streamline the user interface. - Updated the DataTableToolbar component to accept a null customSearch prop, enhancing flexibility in toolbar configuration. * feat(routes): implement section-based routing for system settings and dashboard - Introduced section-based routing for system settings and dashboard features, enhancing navigation and organization. - Updated route definitions to include dynamic sections, allowing for more granular access to settings and dashboard components. - Refactored existing routes to redirect to default sections when no specific section is provided, improving user experience. - Added new section routes for models, usage logs, and system settings, ensuring consistency across the application. - Removed deprecated routes to streamline the routing structure and improve maintainability. * refactor(usage-logs): update column helper functions to require config parameter - Modified createFailReasonColumn and createProgressColumn functions to require a config parameter instead of allowing it to be optional. - Simplified destructuring of config to enhance clarity and ensure necessary properties are always provided, improving code reliability. * refactor(usage-logs): improve section ID validation and routing logic - Introduced a type guard function, isUsageLogsSectionId, to validate section IDs, enhancing type safety and reducing the need for casting. - Updated UsageLogsContent to utilize the new validation function for determining the active category, improving clarity and reliability. - Refactored routing logic to use isUsageLogsSectionId for section validation, ensuring proper redirection to the default section when necessary. * refactor(calendar): update locale documentation for i18n support - Revised the locale prop documentation in the Calendar component to specify the use of react-day-picker for internationalization, clarifying the expected locale setup for users. * chore(i18n): remove redundant user information description from locale files - Removed the user information description from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to streamline translations and improve clarity. * chore(i18n): streamline locale files by removing redundant entries - Removed unnecessary entries from English, French, Japanese, Russian, Vietnamese, and Chinese locale files to enhance clarity and reduce clutter. - Adjusted translations for consistency and improved user experience across multiple languages. * chore(sidebar): remove deprecated usage logs route from sidebar config - Eliminated the '/usage-logs' entry from the sidebar configuration to streamline navigation and improve clarity in the sidebar structure. * refactor(redemption-codes): enhance internationalization support and improve UI consistency - Updated various components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Added meta labels for table columns to improve accessibility and clarity. - Revised confirmation and action texts in dialogs and tooltips to leverage translation, enhancing user experience. - Updated locale files to include new translations for improved clarity and consistency. * feat(masked-value-display): add MaskedValueDisplay component for sensitive data handling - Introduced a new MaskedValueDisplay component to display masked values with a popover for full value visibility and a copy button for easy access. - Updated api-keys-columns and redemptions-columns to utilize the new component, enhancing code reusability and UI consistency. - Revised translation keys in locale files to remove colons for improved clarity. * refactor(url-utils): simplify query parameter matching logic in checkIsActive function - Updated the checkIsActive function to streamline the logic for matching URLs with and without query parameters. - Removed unnecessary checks for query parameters when matching base paths, improving clarity and maintainability of the code. * fix(channels-table): update group filter label to use translation function - Replaced hardcoded 'All Groups' label with a translation function call to enhance internationalization support in the ChannelsTable component. * chore(api-keys): remove deprecated API key action messages and related exports - Deleted the api-key-actions.ts file, which contained action messages for enabling, disabling, and deleting API keys. - Updated index.ts to remove the export of getApiKeyActionMessage, streamlining the codebase by eliminating unused functionality. * refactor(i18n): enhance internationalization support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised constants and labels in the channels and redemption codes features to use i18n keys, improving maintainability and clarity. - Ensured that success and error messages leverage translation functions, enhancing user experience and accessibility. - Streamlined the handling of i18n keys in the constants files for better organization and clarity. * refactor(i18n): enhance translation support across various components - Updated multiple components to utilize translation functions for user-facing strings, ensuring a consistent experience across different languages. - Revised pagination and status labels to use i18n keys, improving maintainability and clarity. - Enhanced response time formatting to support internationalization, allowing for localized display of time values. - Updated locale files to include new translations for improved clarity and consistency. * docs(AGENTS): add type checking requirement for TypeScript changes - Included a new guideline stating that type checks must be executed after modifying TypeScript or TSX code, ensuring no type errors are left unresolved. - Updated the document to reflect this addition in the relevant section for better clarity on coding standards. * feat(combobox-input): add ComboboxInput component for enhanced token selection - Introduced a new ComboboxInput component to facilitate token name selection with search and filtering capabilities. - Integrated the ComboboxInput into the UsageLogsFilterDialog for improved user experience when filtering by token name. - Updated locale files to include new translations for user-facing strings related to token filtering. * feat(combobox): integrate translation support for custom value prompt - Added translation functionality to the Combobox component, replacing hardcoded text with a translatable string for the custom value prompt. - Utilized the useTranslation hook from react-i18next to enhance internationalization support, ensuring a consistent user experience across different languages. * refactor(i18n): improve Chinese translations for consistency and clarity - Adjusted spacing in various Chinese translations to enhance readability and maintain consistency across the locale file. - Updated multiple user-facing strings to ensure proper formatting and alignment with localization standards. * feat(calendar): add CalendarDropdown component for enhanced dropdown functionality - Introduced a new CalendarDropdown component to improve user interaction with dropdown selections in the calendar. - Implemented state management for dropdown visibility and selection handling, enhancing the overall user experience. - Updated styling for dropdown elements to ensure consistency and better alignment with the UI design. * fix(balance-query-dialog): handle null currentRow and improve usage query logic - Updated the BalanceQueryDialog component to safely access currentRow properties using optional chaining. - Added a check to ensure currentRow is not null before proceeding with usage queries, preventing potential runtime errors. - Refactored the handleQueryCodexUsage function to use a local variable for currentRow, enhancing code clarity. * feat(i18n): add new translations for batch creation and channel updates - Added new translation strings for batch creation instructions across multiple languages, enhancing user guidance. - Included translations for the "Update Channel" prompt to improve clarity in channel configuration settings. - Ensured consistency in terminology across locale files for better user experience. * feat(channel-mutate-drawer): improve API key input handling and update translations - Refactored the API key input logic in the ChannelMutateDrawer component to enhance readability and maintainability. - Added new placeholder translations for batch creation and existing key prompts in multiple languages, improving user guidance. - Ensured consistency in translation strings across locale files for better user experience. * feat(fetch-models-dialog): implement sorting for model categories - Added a new function to sort model categories alphabetically, placing 'Other' at the end for easier navigation. - Updated the rendering logic in the FetchModelsDialog component to utilize the new sorting function for both new and existing models, enhancing user experience. * refactor(wallet-stats-card): standardize props usage and improve layout consistency Standardizes props usage and improves layout consistency in wallet stats card Refactors the wallet stats card component to: - Use props directly instead of destructuring for consistency - Add min-w-0 to prevent content overflow - Adjust text sizing with break-all for proper wrapping - Implement responsive font sizes (3xl on mobile, 4xl on larger screens) - Improve leading and tracking for better readability Refactor wallet stats card for consistency and layout Standardizes props usage and improves layout consistency in wallet stats card - Uses props directly instead of destructuring for consistency - Adds min-w-0 to prevent content overflow - Adjusts text sizing with break-all for proper wrapping - Implements responsive font sizes (3xl on mobile, 4xl on larger screens) - Improves leading and tracking for better readability * feat(web): add subscription management and admin settings UI * feat(web): add subscription management and admin settings UI - Add subscription management module (plans, pricing, toggle status, and related dialogs/tables with Stripe/Creem integration notes) - Add channel affinity (rules and cache stats), Waffo integration, performance, and Grok model sections to system settings, with extended types and section registry - Add status code mapping validation/risk warnings, upstream update hooks, and utilities for channels; add available models and sidebar module cards to user profile - Add chat2link route and useMinimumLoadingTime, useTableCompactMode shared hooks Made-with: Cursor * fix: remove duplicate GenerateOAuthCode and add missing TaskBulkUpdate - remove duplicate GenerateOAuthCode from github.go since oauth.go already has the generic version. - add model.TaskBulkUpdate for bulk update by upstream task_id strings, fixing task_video.go build failure. * feat(router): add chat2link and subscriptions routes - register /chat2link page route under authenticated layout. - register /subscriptions/ page route under authenticated layout. - update auto-generated routeTree type definitions and route mappings. * feat(docker): add development environment setup with Docker Compose - Introduced docker-compose.dev.yml for local development, including services for new-api, Redis, and PostgreSQL. - Created Dockerfile.dev for backend-only builds, optimizing the development workflow. - Updated makefile to include new commands for starting backend services and frontend development. * feat(web): complete i18n coverage for setup wizard and add language switcher - wrap all hardcoded English strings in setup-wizard, database-step, usage-mode-step, and complete-step with t() calls, covering step titles, descriptions, form validation messages, and fallback strings. - add LanguageSwitcher component to the top-right corner of the setup page so users can switch language during initial setup. - register 25 dynamic i18n keys in static-keys.ts and provide full translations for zh/en/ja/fr/ru/vi. * feat(i18n): internationalize default version text in workspace-switcher - remove hardcoded 'Unknown version' default, use t('Unknown version') for i18n fallback - add "Unknown version" translation entries across all 6 locale files (zh/en/fr/ru/ja/vi) * feat(i18n): add full i18n coverage for channel-affinity settings page - replace Chinese t() keys with English keys across three channel-affinity components to align with new frontend i18n conventions. - add 51 translation entries to all 6 locale files (en/zh/ja/fr/ru/vi) covering main page, rule editor, and cache stats dialog. - register section-registry dynamic keys in static-keys.ts. * feat(i18n): add full i18n coverage for Waffo payment settings page - replace Chinese i18n keys with English keys in waffo-settings-section.tsx for consistency. - wrap previously hardcoded labels (Pay Method Type / Pay Method Name) with t(). - add 26 Waffo-related translation entries across all 6 locale files (en/zh/fr/ru/ja/vi). * feat(i18n): add missing translations for global model settings page - add all 6 locale translations for 3 missing t() keys in global-settings-card. - register dynamically used 'Grok' key in static-keys.ts for i18n scanner coverage. * feat(i18n): add full i18n coverage for Grok model settings page - add translations in all 6 locales (en/zh/fr/ja/ru/vi) for grok-settings-card t() calls. - cover violation fee toggle, amount input, and official docs link labels. - include section-registry descriptionKey translation entries. * feat(i18n): add full i18n coverage for performance settings page - migrate all t() keys from Chinese to English to align with project conventions. - add translations for all 6 locales (en/zh/ja/fr/ru/vi) covering disk cache, system monitoring, log management, and stats dashboard sections. - remove 71 obsolete Chinese-keyed entries from every locale file. * fix(i18n): add 116 missing English translation keys across all locales - scan all t() calls to identify English keys used in code but absent from locale files. - add translations for zh/en/fr/ja/ru/vi, keeping key sets and sort order consistent. - covers system-settings, channels, models, auth, wallet and other modules. * fix(i18n): add missing translations for log cleanup quick-select and confirm dialog - wrap quick-select button labels (24 hours ago / 7 days ago / 30 days ago) with t(). - replace hardcoded English strings in purge confirm dialog with t() calls and date interpolation. - add 5 new translation keys across all 6 locale files (zh/en/fr/ja/ru/vi). * refactor(web): unify all time display with dayjs formatting - replace all toLocaleString/toLocaleDateString/toLocaleTimeString and manual padStart concatenation with dayjs.format(). - standardize output: datetime as YYYY-MM-DD HH:mm:ss, date as YYYY-MM-DD, time as HH:mm:ss. - add formatDateTimeStr, formatDateStr, formatTimeStr dayjs-based helpers in lib/format.ts. - update 12 files across core utils and feature components. * refactor(web): replace native datetime-local input with DateTimePicker in announcements - swap browser-native datetime-local for the project's DateTimePicker component to match the UI used in log cleanup and other pages. - convert between Date objects and ISO strings to bridge the form's string-based schema. * refactor(web): replace native HTML elements with design system components - replace ~35 native <button> with <Button> across pricing, profile, channels modules - replace native <input>/<textarea>/<label> with <Input>/<Textarea>/<Label> for consistent form styling - replace native <table> with <Table> components, <details>/<summary> with <Collapsible> - replace decorative <hr> with <Separator> to ensure global UI consistency * refactor(web): enhance profile components with design system consistency - update ProfileSecurityCard to use buttons for security actions, improving accessibility and styling. - modify AccountBindingsTab layout to a grid for better responsiveness and visual alignment. - refactor NotificationTab to utilize icons for notification methods, enhancing user experience and clarity. * fix(i18n): complete i18n coverage for profile page components - wrap passkey card status badges (enabled/disabled, backup state) and last-used text with t() - fix hardcoded button labels in security dialogs (change password, access token, delete account) - internationalize all 2FA dialog strings (setup, disable, backup codes) - fix email bind dialog description and button state text missing i18n - wrap remaining hardcoded strings in notification tab and checkin calendar - add all missing translation entries to zh.json and en.json * fix(i18n): enhance error messages with translations for deployment access and settings - wrap connection error messages in DeploymentAccessGuard and IoNetDeploymentSettingsSection with t() for internationalization. - add missing translation key for "io.net model deployment is not enabled or api key missing" in all locale files (en, fr, ja, ru, vi, zh). * 🧹 chore(web): resolve all ESLint errors and warnings Align the Vite/React frontend with the current ESLint flat config and React Compiler–related rules by fixing violations instead of broad suppression where practical. - Replace `any` with concrete types (`unknown`, `Record<string, unknown>`, domain types) where upstream/API shapes allow - Fix duplicate imports, unused bindings, `no-console`, and empty blocks - Address react-hooks issues: reorder declarations, memoize unstable callbacks (`useCallback`), extend dependency arrays, and use targeted disables only where sync-from-props in `useEffect` is intentional - Refactor `motion.create` usage in ai-elements shimmer to avoid creating components during render (static-components) - Stabilize TanStack Query/Mutation hook usage (query keys, `mutate` in deps) and add narrowly scoped rule disables where the linter conflicts with library patterns - Disable `react-hooks/incompatible-library` in ESLint config for TanStack Table / RHF false positives - Add file-level `react-refresh/only-export-components` disables for registry/provider/column modules that intentionally mix exports `bun lint` completes with 0 errors and 0 warnings. * ✨ feat(web): add subscription management to sidebar and align drawer with project conventions - Register "Subscription Management" nav item in the admin sidebar group with CreditCard icon pointing to /subscriptions - Add subscription module to sidebar config defaults and URL mapping so it integrates with the admin sidebar modules toggle in system settings - Add subscription entry to sidebar-modules-section moduleMeta for the maintenance settings UI - Refactor SubscriptionsMutateDrawer to follow the same patterns used by users, redemption-codes, and other mutate drawers: - Use shadcn Form/FormField/FormItem/FormControl/FormLabel/FormMessage instead of raw register() + Label + manual error display - Move SheetFooter outside the form with form attribute association - Use SheetClose for the cancel button - Reset form state on drawer close - Align SheetContent width (sm:max-w-[600px]) and spacing conventions * ✨ feat(web): overhaul UI/UX with Vercel Geist design alignment Refactor the entire frontend UI/UX to align with Vercel/OpenAI design principles, covering layout, animations, skeleton loading, and overall visual polish. Motion & Page Transitions: - Add centralized motion system (lib/motion.ts) with Vercel-style transition presets, stagger variants for tables, cards, and sidebars - Implement AnimatedOutlet for route-level page enter animations using TanStack Router pathname keying - Add PageTransition, StaggerContainer, StaggerItem, CardStagger, and TableStagger wrapper components for progressive reveal effects Skeleton Loading — Vercel Geist Style: - Replace shadcn default `animate-pulse` with Geist-style shimmer sweep animation (linear-gradient + background-position keyframes) - Add `--skeleton-base` / `--skeleton-highlight` CSS variables tuned for both light and dark themes with neutral oklch tones - Override auto-skeleton-react inline styles via CSS to unify all skeleton elements under the same shimmer effect - Update TableSkeleton with varied column widths for a natural feel - Add ContentSkeleton and QuerySkeleton wrappers for auto-skeleton integration with React Query error/loading states - Respect prefers-reduced-motion: disable shimmer for accessibility Layout & Sidebar: - Upgrade sidebar expand/collapse transitions to cubic-bezier easing - Add hover micro-interactions (background-color, color, transform) to sidebar menu buttons with smooth 150ms transitions - Fix oklch color compatibility in sidebar outline variant - Integrate AnimatedOutlet into AuthenticatedLayout for unified route-level animations Theme & CSS: - Streamline theme.css with cleaner oklch color definitions - Add CSS table row stagger-in animations with nth-child delays - Fix hover-scrollbar color bug (hsl → color-mix for oklch compat) - Add content-auto utility for long list rendering optimization Cleanup: - Remove deprecated skeleton-wrapper.tsx - Remove unused imports and dead code across components - Add empty-state, error-state, and loading-state utility components * 🐛 fix(docker): track bun.lock to fix Docker build failure Remove `web/bun.lock` from `.gitignore` so the lock file is committed to version control. The Dockerfile `COPY web/bun.lock .` instruction requires this file to be present in the build context, and ignoring it caused the build to fail with a "not found" error. * ⬆️ chore(web): upgrade dependencies and fix all type/lint errors Upgrade all frontend dependencies to latest stable versions: - lucide-react 0.562 → 1.7 (major: brand icons removed) - shiki 3.x → 4.x, eslint 9.x → 10.x, knip 5.x → 6.x - @rsbuild/core 1.3 → 1.7, @types/node 24 → 25 - tailwindcss/postcss 4.1 → 4.2, motion 12.25 → 12.38 - @tanstack/react-query 5.90 → 5.95, zod 4.3.5 → 4.3.6 - react 19.2.3 → 19.2.4, axios 1.13.2 → 1.13.6 - prettier 3.7 → 3.8, typescript-eslint 8.52 → 8.57 - Add missing optional deps: @xyflow/react, embla-carousel-react Resolve all TypeScript compilation errors introduced by upgrades: - Replace lucide-react brand icons (Github) with react-icons/si - Fix react-hook-form Control/Resolver generics for zod v4 - Fix Record<string, unknown> type constraints across API utils - Fix axios interceptor return types in lib/api.ts - Add type assertions for useSettings/useStatus hook returns - Resolve Badge variant, spread type, and route path mismatches Resolve all ESLint 10 errors: - preserve-caught-error: attach cause to re-thrown errors - no-useless-assignment: refactor redundant variable assignments - prefer-as-const: use `as const` over literal type assertions - no-unused-vars: prefix type-only schemas with underscore Update tsconfig lib from ES2020 to ES2022 for Error.cause support. * 🐛 fix(web): stop pricing model row from centering its content Wrapping the row in shadcn <Button variant='ghost'> inherits `justify-center`, and the inner flex container had no width, so `justify-between` collapsed and the row appeared centered. * feat: add Waffo payment integration and related UI components - Introduced Waffo payment method with support for custom icons and settings. - Updated payment settings section to include Waffo settings. - Added Waffo payment request handling in the wallet API. - Enhanced wallet recharge form to support Waffo payment methods. - Implemented hooks for Waffo payment processing. - Updated localization files for new Waffo-related strings. - Added new payment type and icon for Waffo in constants and UI components. - Refactored topup info handling to include Waffo payment methods and configurations. * feat(profile): add admin-only upstream model update notification setting * fix(web): make sidebar module user settings actually take effect Previously, saving sidebar module preferences in profile had no effect because the client ignored user-level sidebar_modules entirely. This fix wires user config into useSidebarConfig so the sidebar updates immediately without a page refresh. Changes: - Add UserPermissions type with sidebar_settings/sidebar_modules fields - Refactor useSidebarConfig to merge admin × user config with AND logic - Sync sidebar_modules to auth store on save for immediate UI updates - Conditionally render SidebarModulesCard based on user permissions - Treat null/empty user config as "do not narrow" for legacy users * feat(web): add custom OAuth provider CRUD and login button support Migrate custom OAuth from v1 to v2: - Admin CRUD UI with provider table, form dialog, preset templates, and OIDC discovery - Login page renders dynamic buttons for custom OAuth providers - Fix account bindings display showing "Not bound" text when already bound * feat(web): add ServerAddress, SMTPForceAuthLogin, CreateCacheRatio and group special usable settings Migrate missing v1 system settings to v2: - ServerAddress input in General > System Information - SMTPForceAuthLogin toggle in Integrations > Email - CreateCacheRatio JSON editor in Models > Ratio - Group special usable group rules editor in Models > Ratio * feat(web): wire user subscriptions dialog to users table row actions The UserSubscriptionsDialog component already existed but had no entry point in the users table dropdown menu. Add "Manage Subscriptions" menu item. * chore(web): update i18n translations for new settings and custom OAuth * 💎 refactor(web): redesign pricing page with flat, typography-driven layout * 🌐 chore(i18n): complete missing translations and normalize project config - Add 425+ missing translations across fr, ja, ru, zh, vi locales for subscription management, sidebar navigation, Grok settings, upstream model updates, pricing page, and other UI components - Add 37 missing i18n keys used in t() calls but absent from locale files (pricing filters, display options, audio/cache labels, etc.) - Fix stale tech stack info in CLAUDE.md, AGENTS.md, and project.mdc: React 18 → 19, Vite → Rsbuild, Semi Design → Radix UI + Tailwind - Fix i18n key format description: "Chinese source strings" → English - Deduplicate .cursor/rules/project.mdc to avoid triple-loading the same rules already present in root CLAUDE.md and AGENTS.md - Add i18n-translate Cursor skill for repeatable translation workflow * 🎨 refactor(web): redesign dashboard with flat, typography-driven layout Replace Card-based dashboard components with a flat, border-driven design system consistent with the pricing page, following the ui-style.mdc conventions. Overview section: - StatCard: replace Card wrapper with flat flex layout, monospace tabular values, uppercase tracking-wider labels, layered opacity hierarchy - PanelWrapper: replace Card/CardHeader/CardContent with rounded-lg border container and border-b header - SummaryCards: merge three stat cards into a single bordered container with divide-x separators; decouple border from stagger animation to prevent border deformation during entrance transitions - ApiInfoPanel/Item: full-width list rows with border-b separators, monospace route names, layered opacity for URLs and descriptions - AnnouncementsPanel: native button rows with hover:bg-muted/40, i18n for "Click for details" hint - FAQPanel: lighter border-border/60 accordion dividers, muted answer text - UptimePanel: uppercase tracking-wider group headers with bg-muted/30 background, monospace uptime percentages, fine-grained border opacity Models section: - LogStatCards: replace Card with rounded-lg border + divide-x grid, fix react-hooks/exhaustive-deps by destructuring props before useEffect - ModelCharts: replace Card+Tabs with bordered container + custom segmented control matching ui-style spec - Suspense fallbacks: match new flat skeleton layout with accurate column structure Animation: - Wrap models section in FadeIn with staggered delay - Keep CardStagger for overview panel grid (each panel has own border) Other: - Add ui-style.mdc cursor rule documenting the design language - Disable react-refresh/only-export-components for src/routes/** in eslint config (TanStack Router route files always export Route objects) - Fix zh.json: "Token-based" translation "基于令牌的" → "按量计费" * ✨ refactor(web): adopt flat dot-and-text design for all status badges Replace the bordered/colored-background StatusBadge and Badge components across the entire frontend with a minimal flat design: a small colored dot followed by colored text, eliminating visual noise from heavy borders, backgrounds, and rounded pill shapes. Key changes: - Redesign StatusBadge to use dot + text instead of bordered pill style, removing cva-based background/border variants in favor of exported dotColorMap and textColorMap lookup tables - Add children prop support to StatusBadge for flexible content rendering alongside the existing label prop - Migrate all Badge usages (except pricing page) to StatusBadge with appropriate variant mappings (default→info, secondary→neutral, outline→neutral, destructive→danger) - Consolidate adjacent multi-badge groups into single-dot layouts with dot separators (·) to reduce visual clutter in: - Channel balance columns (used + remaining) - Channel type column (type + IO.NET indicator) - User invite info column (invited + revenue + inviter) - Usage log stats bar (usage + RPM + TPM) - Usage log time/FRT column (time + FRT + stream status) - Subscription plan counts (active + expired) - Channel affinity scope/regex/key-source columns - Prefill group card headers (type + ID) - Export dotColorMap and textColorMap for direct use in custom inline layouts that need consistent status colors without the full component * ✨ refactor(web): redesign public layout and landing page with modern UI Overhaul the public-facing layout, header, and homepage to deliver a polished, animation-rich landing experience inspired by contemporary SaaS design patterns. Header: - Replace sticky header with fixed floating navbar that compacts into a pill-shaped glass-morphism bar on scroll (backdrop-blur + ring) - Add smooth 700ms cubic-bezier transitions for scroll-based shrinking - Build full-screen mobile menu overlay with staggered entry animations - Remove background color from logo container, show logo image directly Homepage sections: - Hero: gradient text title, radial gradient + grid pattern background, interactive terminal demo showcasing API request/response - Terminal demo: auto-cycles through gpt-4o, claude-sonnet-4-20250514, gemini-2.5-pro, deepseek-chat with smooth cross-fade transitions, clickable model badges, dual theme support (light/dark), fixed height - Stats: animated counters driven by IntersectionObserver with cubic-bezier easing, supports integer and decimal modes - Features: Bento grid layout with gap-px border technique, each card includes contextual visuals (model list, security badge, workflow) - How It Works: new three-step process section (Configure → Connect → Monitor) with connecting gradient line and numbered badges - CTA: gradient mesh background with scale-in scroll animation - Footer: streamlined brand column + link columns layout New components: - AnimateInView: IntersectionObserver-based scroll animation component supporting fade-up, fade-in, scale-in, fade-left, fade-right - HeroTerminalDemo: themed terminal with model carousel and live request/response preview CSS: - Add landing page scroll-triggered keyframe animations - Add terminal demo animations (blink cursor, spinner, pulse indicator) - Respect prefers-reduced-motion throughout i18n: - Add 17 new translation keys across all 6 locales (en/zh/fr/ja/ru/vi) * ✨ feat(web): align usage logs and channels with legacy UI Usage logs - Show Refund (type 6) in detail dialog and hide conversion chain for refunds - Sync filter dialog state from URL for model, token, group, username, and requestId Channels - Support optional stream flag in channel test API, actions, and test dialog - Show upstream model update badges (+added / -removed) on fetchable channel types - Add form fields and drawer toggles for upstream model update check and auto-sync - Persist upstream model update flags in channel settings JSON for fetchable types i18n - Add locale strings for upstream model update UI (en, zh, fr, ja, ru, vi) * 🐛 fix(web): prevent transient vertical scrollbar on tables during animations Add overflow-y-clip to the shared Table container (data-slot=table-container) alongside overflow-x-auto. Setting overflow-x to auto implicitly pairs with overflow-y: auto in browsers, which made the table shell briefly show a vertical scrollbar during route enter motion (y/blur) and table row stagger. Remove the redundant descendant selector workaround from the model pricing GroupPricingSection; behavior is now covered globally by the Table component. * 🏗️ refactor(web): redesign console layout with fixed header, scrollable content, and pinned footer Overhaul the authenticated console layout to match the OpenAI dashboard pattern: header and page title bar stay fixed at the top, only the content area scrolls, and table pagination is pinned to the bottom. Layout architecture: - Lock SidebarInset to full viewport height (h-svh) so all inner regions are controlled by flexbox instead of document scroll - Convert Main from a generic div to a semantic <main> flex container with overflow-hidden, removing the legacy `fixed` prop and `data-layout` attribute - Strip scroll-shadow logic and `fixed` prop from Header/AppHeader; the header is now naturally fixed as a shrink-0 flex child - Restructure SectionPageLayout into three flex regions: a shrink-0 title bar, a flex-1 overflow-auto content area, and a shrink-0 footer portal target with empty:hidden - Add min-h-0 to AnimatedOutlet wrappers to prevent flex overflow Footer portal system: - Introduce PageFooterProvider / PageFooterPortal (React Context + createPortal) so deeply nested table components can render their DataTablePagination into the fixed footer without prop drilling - Migrate all 8 data tables (api-keys, channels, users, models, deployments, usage-logs, subscriptions, redemption-codes) to use PageFooterPortal for pagination Page-level fixes: - Profile: wrap content in a scrollable flex child with proper padding - SystemSettings: remove overflow-auto from wrapper to avoid nested scrollbars (sub-pages manage their own scroll) - Playground / Error pages: remove obsolete `fixed` props API keys UX improvement: - Replace inline key show/hide toggle with a Popover-based reveal, removing toggleKeyVisibility and keyVisibility state from the provider context Cleanup: - Remove dead CSS rule for body:has([data-layout='fixed']) - Remove unused `fixed` prop from Header, AppHeader, and Main types - Export PageFooterPortal from layout barrel file * 💅 refactor(web): polish table UI consistency and add pagination transitions - Standardize primary action buttons (Create, Add, Search) to size="sm" across all pages for visual consistency with channels and models - Redesign NumericSpinnerInput with minimal inline style: plain text by default, hover-revealed +/- buttons, click-to-edit — replacing the clunky bordered input with stacked chevron arrows - Fix vertical scrollbar in channels group column by replacing overflow-x-auto with overflow-hidden (redundant with +N collapse) - Simplify API keys group column: replace colorful StatusBadge pairs with clean typography using opacity hierarchy and dot separators - Move API key copy loading indicator from key text to the copy button itself, eliminating layout shift during key resolution - Reduce page title from text-2xl to text-lg and subtitle to text-sm in SectionPageLayout for a more compact header - Add smooth opacity transition (duration-150) on all 7 server-paginated tables during background data fetches (isFetching && !isLoading), with pointer-events-none to prevent interaction during loading - Constrain usage logs Details column width (size: 200, maxSize: 220) * 🐛 fix(web): restore missing padding on system settings content The console layout refactor ind2150469moved padding ownership from Main onto each route, but SystemSettings was missed — its Outlet wrapper had no padding, so the content area sat flush against the sidebar and top nav. Add `px-4 pt-6 pb-4` to match the vertical rhythm used by SectionPageLayout and the Profile page. * 📱 refactor(web): standardize mobile responsive layout across all table pages Unify mobile experience for all data table pages (channels, keys, models, deployments, usage-logs, users, redemption-codes, subscriptions) with a consistent layout pattern and cleaner header area. DataTableToolbar: - Redesign mobile layout: full-width search input + collapsible filter toggle button with active filter count badge - Filters, additional search, and reset button collapse into an expandable section on mobile, keeping the default view compact - Desktop layout remains unchanged SectionPageLayout: - Tighten mobile spacing (padding, gaps) for higher content density - Scale down title (text-base) and description (text-xs) on mobile - Shrink action button gaps on small screens ChannelsPrimaryButtons: - Move Tag Mode and Sort by ID toggles into the "More" dropdown on mobile (via DropdownMenuCheckboxItem), freeing header space - Desktop toggle switches remain visible outside the dropdown MobileCardList (shared component): - Compact list-item layout with title + badge header row and side-by-side key fields, replacing individual card components - Structured (CompactRow) and fallback (FallbackRow) rendering modes driven by column meta (mobileTitle, mobileBadge, mobileHidden) New MobileCardList integration: - Users table: username as title, status as badge; hide id, display_name, invite_info on mobile - Redemptions table: name as title, status as badge; hide id, created_time, expired_time, used_user_id on mobile - Subscriptions table: plan title as title, enabled as badge; hide id, sort_order, reset, payment, total_amount, upgrade_group on mobile Column meta updates: - Add mobileTitle/mobileBadge/mobileHidden meta across all 8 table column definitions for consistent mobile field prioritization Minor fixes: - Hide Subscriptions Stripe/Creem alert on mobile - Disable card hover animations on mobile via CSS media query * 🐛 fix(web): sync favicon with custom system logo Favicon stayed at the hardcoded /logo.png while document.title already followed system_name, leaving tab icon and site branding out of sync. Apply the logo as favicon from localStorage cache on startup, refresh from getStatus(), and re-apply when useSystemConfig finishes preloading. Extract applyFaviconToDom helper into lib/dom-utils with idempotent guard to avoid redundant DOM writes. * ✨ feat(web): add channel affinity rule templates and CreateCacheRatio visual editing Port missing features from legacy frontend (b8650b9merge) to the new React frontend: - Add Codex CLI and Claude CLI channel affinity rule templates with header passthrough presets (pass_headers operations for Originator, Session_id, X-Codex-*, X-Stainless-*, Anthropic-*, etc.) - Introduce "Add Rule" dropdown menu with blank, Codex CLI, and Claude CLI template options in the channel affinity settings page - Add "Fill Templates" button to batch-append both CLI templates with duplicate name resolution and confirmation dialog - Support templateKey prop in RuleEditorDialog to pre-fill form fields from selected template, auto-expanding advanced settings when a param_override_template is present - Add CreateCacheRatio support to the model ratio visual editor, edit dialog, and form — previously only editable in JSON mode, now fully integrated into the visual table column, add/edit dialog fields, and save/delete handlers * 🐛 fix(web): fix content-type detection bugs in About and Home pages - Fix About page URL detection: replace naive `startsWith('https://')` with proper `new URL()` validation to support both http and https, and handle untrimmed input that previously caused silent misdetection - Fix About page HTML detection: remove overly broad `startsWith('<')` and `endsWith('>')` checks that could misclassify Markdown or XML content; align with LegalDocument's regex-only `isLikelyHtml` approach - Fix Home page URL detection: same `startsWith('https://')` bug, replaced with `new URL()` protocol validation - Refactor About page to use early-return pattern instead of deeply nested ternary expressions for better readability - Replace About loading spinner with Skeleton placeholder consistent with LegalDocument - Add `prose prose-neutral dark:prose-invert` typography classes to About HTML/Markdown rendering for proper dark mode support - Remove unused `Code` icon import from About page * ✨ feat(web): port missing features from legacy frontend and complete i18n Backport and enhance several features from the old frontend (web/old) that were missing or incomplete in the new React frontend: - Playground & channel test: parse structured JSON error responses from SSE streams and non-streaming API calls, extract error codes, and display actionable UI for `model_price_error` (admin settings link) - User management: replace local quota manipulation with atomic server-side quota adjustments (add/subtract/override) via dedicated API endpoint, making the quota field read-only in the edit drawer - Subscriptions: display next quota reset time for active subscriptions - Dashboard: limit model ranking chart to top 20 models with an "Other" bucket, add dimension tooltips with sorted values and totals to model call trend and user consumption trend charts - i18n: add 24 new translation keys across all 6 locales (en, zh, fr, ja, ru, vi) for the newly introduced UI elements and messages * 🎨 feat: add backend-configurable frontend theme switching (default/classic) Introduce runtime frontend theme switching so administrators can switch between the new frontend (Radix UI + Tailwind) and the classic frontend (Semi Design) from the settings page without restarting the server. Directory restructuring: - Move new frontend from web/ to web/default/ - Move classic frontend from web/old/ to web/classic/ - One-frontend-per-folder layout for extensibility Backend (injection pattern): - Add setting/system_setting/theme.go with GlobalConfig.Register("theme") so the DB key "theme.frontend" is handled automatically by handleConfigUpdate — no switch-case in updateOptionMap needed - Use atomic.Value in common.GetTheme()/SetTheme() for lock-free concurrent reads on the hot path (static file middleware) - Add themeAwareFileSystem that delegates to the correct embedded FS based on the current theme at request time - Embed both frontends into the binary via go:embed - Add controller validation for theme.frontend values - Expose theme in GET /api/status response Frontend settings UI: - New frontend: add "Frontend Theme" select in System Information section using Radix UI Select + react-hook-form + Zod validation - Classic frontend: add "前端主题" select in Personalization section using Semi Design Form.Select Build system: - Update Dockerfile with multi-stage builds for both frontends - Update Makefile with separate build targets for each frontend - Update GitHub Actions release workflow for dual frontend builds i18n: - New frontend: add translations for all 6 locales (en/zh/fr/ja/ru/vi) - Classic frontend: add translations for all 7 locales (en/zh-TW/ja/fr/ru/vi) - Fix zh "AI Proxy Library" → "AI 代理库" Documentation: - Update CLAUDE.md, AGENTS.md, .cursor/rules/project.mdc to reflect the new web/default/ and web/classic/ directory structure * ✨ feat(web): add allow_speed passthrough for Claude channels, fix multi-key index and inference_geo scope - Add `allow_speed` toggle for Anthropic (type 14) channels to control Claude inference speed mode passthrough, with full form schema, settings persistence, and UI switch - Fix `allow_inference_geo` to also apply to Anthropic (type 14) channels, not just OpenAI (type 1), matching the backend behavior for Claude data residency region control - Fix multi-key management dialog to display 1-based key indices instead of 0-based (#{key.index + 1}) - Fix TypeScript type error in section-registry by adding type assertion for theme.frontend enum - Add i18n translations for all new keys across 6 locales (en, zh, fr, ja, ru, vi) * 🧹 chore: clean up editor configs, consolidate agent skills, and set classic as default theme - Add .cursor/ to .gitignore and remove tracked editor config files (.cursor/rules/, .cursor/skills/) from version control - Consolidate .agents/skills/vercel-react-best-practices by keeping only the compiled AGENTS.md and removing redundant SKILL.md and 57 individual rule files under rules/ - Change default frontend theme from "default" to "classic" in both common/constants.go init and setting/system_setting/theme.go * feat: Frontend Tiered Pricing, Waffo Payments, and Rsbuild 2 Upgrade (#24) * feat(ui): add codex extra limits, key last used, and admin audit surfaces - codex usage dialog: render `additional_rate_limits` with `RateLimitGroupSection` and typed base/secondary window data. - api keys table: add "Last Used" column from `accessed_time`. - usage log details: show top-up audit and manage operator for admins; extend `LogOtherData` audit fields; broaden IP display; warn when legacy records lack audit data. - billing history: show user id badge for admins; add zh i18n for new strings. * feat(web): add dynamic pricing breakdown and Waffo Pancake payments - add billing-expr parsing and DynamicPricingBreakdown; surface tiered_expr in model list/details. - extend PricingModel with billing_mode, billing_expr, and pricing_version for backend parity. - add Waffo Pancake integration settings, amount/pay APIs, hook, and recharge flow wiring. - update payment confirm/recharge UI and Chinese locale strings. * feat(pricing): add tiered billing editor and tool price settings - introduce tier-expr and extend billing-expr (time/param conditions, combine/split helpers, editor utilities) for visual tiers and request rules. - support tiered_expr in model ratio dialog, form, and visual editor with billing_setting fields and default JSON placeholders. - add TieredPricingEditor and tool price settings UI plus i18n updates. * chore(web): bump rsbuild to v2 and align build config - upgrade @rsbuild/core, @rsbuild/plugin-react, and Rspack 2 transitives; bump TanStack Router packages and refresh bun.lock. - replace deprecated performance.chunkSplit with top-level splitChunks cache groups for react, radix, and tanstack vendors. - factor dev server proxy into devProxy; set legalComments to none in prod; enable performance.buildCache keyed by VITE_REACT_APP_VERSION. - TanStack Router plugin: enable autoCodeSplitting only in production for faster dev navigation and HMR. * fix(i18n): update translations for API keys and Waffo Pancake settings - Corrected translations for "API Private Key" and "Merchant ID is required" across multiple languages. - Added new translation for "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups." - Updated various existing translations to ensure consistency and clarity in user interface text. * refactor(code-block): simplify code highlighting and improve theme handling - Updated the highlightCode function to support dual themes in a single call, reducing complexity. - Removed unnecessary state management for dark theme HTML, streamlining the component. - Enhanced CSS for Shiki themes to ensure proper token color application in dark mode. * refactor(wallet): use isWaffoPancakePayment for pancake payment dispatch - replace the waffo_pancake string literal with the shared helper for consistency with use-payment and PAYMENT_TYPES. - centralize the value so a constant change does not require hunting for typos in multiple call sites. * fix(wallet): validate waffo pancake checkout url and safe open - allow only parseable http/https redirect targets from the backend, rejecting dangerous schemes. - pass noopener and noreferrer in window.open to reduce reverse tabnabbing. - show a toast and abort on invalid URLs; add i18n entries across locales. * fix(wallet): harden payment icon image URLs - add normalizeHttpIconUrl to allow only http(s) after resolution and reject userinfo in URLs. - set referrerPolicy, lazy loading, and async decode on the icon <img> to cut referrer leakage. - fall back to built-in icons on invalid URLs, same as when iconUrl is missing. * fix(pricing): label param() conditions as body param in dynamic pricing - non-header request rules map to `param()`, not query strings. - align with tiered pricing editor by using the existing `Body param` string. * fix(rsbuild): update legalComments handling in build config - Rely on Rsbuild's default legalComments setting in all modes to ensure compliance with open-source licensing requirements. - Clarified comments to explain the implications of omitting legalComments in production. * fix(i18n): correct billing and codex UI strings in locale files - restore ~83 en.json values to English (tool pricing, audit text, alipay label, etc.). - add proper fr/ru/vi/ja strings so those locales no longer copy zh. - change five locale files only; zh.json unchanged. * fix(i18n): update locale files for improved translations and sync report - Added missing translations and corrected existing strings in English, French, Japanese, Russian, Vietnamese, and Chinese locale files. - Updated the sync report to reflect zero missing translations across multiple locales. - Enhanced the untranslated count for Japanese locale to ensure completeness. - Changed the base locale from zh.json to en.json for better alignment. * chore(agents): add i18n-translate agent skill - add `.agents/skills/i18n-translate/SKILL.md` documenting locale layout under `web/default` and `bun run i18n:sync` usage. - capture a repeatable maintainer workflow with embedded script examples to find missing keys and untranslated values. - give agents a clear path to complete and verify translations across en, zh, fr, ja, ru, and vi. * feat(settings): hide frontend theme setting (#25) * feat(settings): hide frontend theme setting - add a local hidden feature flag with window.newapiUnlock support. - hide the frontend theme option by default and reveal it immediately after unlock. * feat(settings): support click unlock for frontend theme setting - add a shared hidden click unlock hook for repeated-click gated UI. - reveal the frontend theme option after triple-clicking the system information title. - preserve the Doubao API address ten-click unlock behavior and remove global unlock functions. * feat(sync59337e9): Sync classic tiered billing, upstream price synchronization, and model management features to web/default (#26) * feat(skill): add classic-to-default-sync skill for auditing and syncing web/classic changes to web/default - Introduced a new skill to inspect a given commit's changes in web/classic and synchronize features and fixes to web/default. - Documented workflow steps for extracting diffs, mapping changes, triaging, implementing, and reporting on the synchronization process. - Emphasized quality standards and internationalization considerations for new user-visible strings. * feat(web/default): sync billing and model management features from classic - add `len` condition variable (total input context length); introduce BILLING_PRICING_VARS / BILLING_CONDITION_VARS to separate pricing vars from condition-only vars; fix tier condition regex to accept `len`. - rewrite upstream ratio sync components to support per-model grouped rows and new ratio types (create_cache, image, audio, billing_expr). - add LlmPromptHelper component; update tiered presets to use `len` for conditions; add GLM-4.5 Air, Doubao Seed 1.8, Qwen3 Omni Flash, and weekend-discount presets. - add created_at / last_login_at columns to users table; add "Removed Models" tab to FetchModelsDialog for mapping source keys not in the models list. - add extractMappingSourceModels helper; update dynamic-pricing-breakdown to use system currency settings; add 19 i18n keys across all locales. * ✨ feat(default): surface tiered billing in usage logs and gate Passkey ops behind 2FA Continues the classic-to-default sync (commit1be6cdb) by porting the remaining audit-log, pricing-hint, and Passkey lifecycle features from web/classic to web/default using the default frontend's component patterns (Radix UI, Tailwind, shadcn-style dialogs). * feat(usage-logs): show tiered_expr breakdown and matched tier in details - Extend `LogOtherData` with `billing_mode`, `expr_b64`, and `matched_tier` fields populated by the backend for tiered logs. - Add `decodeBillingExprB64`, `resolveMatchedTier`, and `getTieredBillingSummary` helpers in `usage-logs/lib/format.ts` that centralise tiered-billing parsing on top of the canonical `parseTiersFromExpr` / `BILLING_PRICING_VARS` from the pricing feature, instead of duplicating the classic-frontend renderer. - Render `<DynamicPricingBreakdown>` inside the consume-log details dialog with the matched tier row highlighted in emerald and tagged "Matched"; suppress the legacy claude/audio/image cost rows when a tiered expression is in effect. - Surface per-tier prices and the matched tier label in log row segments and the billing breakdown table. * feat(pricing): show tier-count, time-based, and request-based hints in model list - Add `summarizeTieredExpr` that derives compact dynamic-pricing metadata (tier count + presence of time/request conditions) from a `tiered_expr` model, computed once per render via `useMemo`, so users can tell *what kind* of dynamic pricing applies before drilling into the model details. - Render the hints alongside the existing "Dynamic Pricing" badge in `<ModelRow>`. - Extend `<DynamicPricingBreakdown>` with a `matchedTierLabel` prop so the same component can be reused from the usage-log details dialog to highlight the tier that actually fired. * feat(profile): require Security Verification for Passkey register/remove - Wire `usePasskeyManagement` through `useSecureVerification` and `<SecureVerificationDialog>` in `<PasskeyCard>`. - Registration prompts for 2FA before issuing the Passkey credential (only when 2FA is already enabled — otherwise the browser-level Passkey prompt itself acts as proof of presence and we register directly). - Removal prompts for 2FA or Passkey, whichever the account has enabled, with informative toasts when neither method is available or the device lacks Passkey support. - Scope the dialog method set to the required factor so users cannot fall back to a weaker method, and propagate cancellation cleanly. * refactor: tighten upstream-ratio-sync and fix tier editor narrowing - Drop the unused `hasSynced` state and dead `getOrderedRatioTypes` / `isSelectableUpstreamValue` imports from `upstream-ratio-sync.tsx`. - In the cost estimator, narrow `BILLING_EXTRA_VARS` entries with a null-`field` guard to silence the type checker and make the "pricing variables only" contract explicit. - Apply Prettier-consistent formatting to the upstream-ratio-sync table/columns, channel mutate drawer, system info section, tier-expr, and wallet helpers (no behaviour change). * i18n: add 9 keys across en/zh/fr/ja/ru/vi - `{{count}} tiers`, `Billing Process`, `Matched`, `Matched Tier`, `Request-based`, `Security verification`, `Time-based`, plus the two new Passkey verification description strings. * 🔧 refactor(default): align upstream price sync, tiered billing, and fetch-models with classic59337e9Port and optimize the remaining web/classic features from commit59337e9to web/default, covering upstream price synchronization, tiered billing expressions, model fetching, and channel preset detection. Improve component architecture, memoization, and i18n coverage. Upstream Price Sync - Extend sync to all ratio fields: CacheRatio, CreateCacheRatio, ImageRatio, AudioRatio, AudioCompletionRatio in addition to ModelRatio / CompletionRatio / ModelPrice - Add tiered billing sync (billing_mode + billing_expr) with auto-pairing so selecting one upstream tier value populates the other from the same source - Bulk select / unselect per upstream column with indeterminate checkbox state reflecting partial selection - Confidence indicators warn when an upstream entry is heuristically derived - Conflict confirm dialog gains loading state and disables actions during sync - Default endpoint per channel: /api/pricing for official preset, /api/models.dev for the models.dev preset, /api/v1/models for OpenRouter, with the rest falling back to the global default - Rename tab label from "Upstream sync" to "Upstream price sync" for clarity Tiered Pricing Editor - Add `len` (full input length, including cache hits) as a tier-condition variable to avoid mis-routing when cache hits reduce `p` - When inserting a new tier, automatically convert the previous catch-all into a bounded tier with a `len <= X` upper bound - Cap each tier at 0~2 conditions and disable the add-condition button at the limit, with an Alert explaining the recommended `len` usage - Extend presets with Multimodal (img / img_o / ai / ao), Request rule (header/param matching), and Time-based (hour / weekday) entries - Embed an LLM prompt helper that copies a model-aware template for designing expressions with ChatGPT / Claude Fetch Models Dialog - Add a "Removed Models" tab listing models still in the local selection but no longer returned by the upstream listing - Exclude `model_mapping` source keys from the removed view so aliases never appear as missing entries - Force-remount tab content on tab switch via `key` prop to clear stale state - Switch count placeholders to `{{count}}` interpolation across "Existing Models", "New Models", and "Removed Models" labels Channel Selector & Constants - Recognize the models.dev preset (id, base_url, name) alongside the existing official-channel preset detection - Add MODELS_DEV_PRESET_* and OPENROUTER_* constants and reorder ENDPOINT_OPTIONS so `pricing` is preferred over `ratio_config` - Expose the new ratio types in RATIO_TYPE_OPTIONS for the sync filter Types - Add optional `type` field to UpstreamChannel for endpoint inference - Extend RatioType union with create_cache_ratio, image_ratio, audio_ratio, audio_completion_ratio, billing_mode, and billing_expr Code Quality & Performance - Extract upstream-ratio-sync-helpers.ts to host shared types (RatioDifferenceEntry, ModelRow, ResolutionsMap), field ordering (RATIO_SYNC_FIELDS, SYNC_FIELD_ORDER, NUMERIC_SYNC_FIELDS), and selection logic (getPreferredSyncField, isSelectableUpstreamValue, getSyncFieldLabel) - Memoize the column definitions in useUpstreamRatioSyncColumns and pull the per-cell rendering into a renderUpstreamValue helper to remove inline IIFEs - Wrap handleBulkSelect / handleBulkUnselect in useCallback for stable refs; rename the misleading `_upstream` parameter to `upstream` - Convert parsedRatios from useCallback (returning a function) to useMemo (returning the value) and update all call sites to read it as a value - Memoize the channels list with useMemo so the endpoint-init effect no longer fires on every render due to a fresh `?? []` reference i18n - Add and translate new keys ("Upstream price sync", "Audio Ratio", "Audio Completion Ratio", "Cache Create Ratio", "Image Ratio", "Expression Billing", "Fixed Price", "{{n}} model(s) selected", tier guidance, etc.) across en, zh, fr, ja, ru, vi - Fix truncated keys ("Existing Models (", "New Models (", "Removed Models (") to proper {{count}} interpolated forms in every locale - bun run i18n:sync reports 0 missing and 0 extra keys for every locale Verification - bun run typecheck: pass - bun run lint: pass - bun run i18n:sync: pass (0 missing / 0 extras across all locales) * 🐛 fix(default): port classic73e5557tiered-billing fixes and dedupe Title-Case ratio i18n keys Sync the web/classic frontend fixes from upstream merge73e5557to web/default, and clean up duplicated Title-Case ratio labels in the upstream sync UI that were shadowing the canonical sentence-case i18n keys. Cache-token filter for tiered model price (port of9f8a4ec05) - The matched-tier breakdown shown in the usage-log details dialog and in the log table previously listed every cache-related price (Cache Read, Cache Write, Cache Write 1h) regardless of whether the request actually consumed cache tokens. - `getTieredBillingSummary` in `usage-logs/lib/format.ts` now skips `cache`-group vars when none of `cache_tokens`, `cache_creation_tokens`, `cache_creation_tokens_5m`, or `cache_creation_tokens_1h` are positive, mirroring the classic `renderTieredModelPrice` / `renderTieredModelPriceSimple` logic. - Extract `hasAnyCacheTokens(other)` as an exported helper so the predicate is defined once. - Add a `hideCacheColumns?: boolean` prop to `DynamicPricingBreakdown` and wire it up from the log details dialog so the full tier table hides cache columns under the same condition. `model-details.tsx` keeps the default (show all configured prices), since that view represents the model's pricing structure rather than a specific call. `tiered_expr` ratio/price fallback during sync delays (port ofbee339d27) - When saving a model in tiered-expression mode, the visual editor used to delete every ratio/price map entry for the model and only write `billing_setting.billing_mode` / `billing_setting.billing_expr`. In multi-instance deployments, instances that had not yet observed the billing_setting update fell back to ratios that no longer existed, breaking pricing. - `model-ratio-dialog.tsx`: `handleSubmit` always passes every form field (`price`, `ratio`, `cacheRatio`, `createCacheRatio`, `completionRatio`, `imageRatio`, `audioRatio`, `audioCompletionRatio`) into the data object regardless of `pricingMode`, so a switch from per-token to tiered_expr no longer drops the previously entered ratios. - `model-ratio-visual-editor.tsx`: - The row builder now also surfaces ratio/price values for `tiered_expr` rows, so they survive the edit-and-save round trip and the next save. - `handleSave` factors out a `setIfPresent` helper and persists ratio/price entries for `tiered_expr` models alongside billing_mode / billing_expr. These act purely as fallback because the backend's `ModelPriceHelper` checks `billing_mode` first. - Cell rendering mutes ratio/price values whenever the row is `tiered_expr` (in addition to the existing per-request muting), making it visually clear the values are fallback, not the active pricing source. i18n: dedupe Title-Case ratio labels in upstream sync - `upstream-ratio-sync` `RATIO_TYPE_OPTIONS` previously used Title-Case labels (`Model Ratio`, `Cache Ratio`, `Audio Completion Ratio`, …) that were rendered through `t()` but never existed as canonical keys in the catalogue. The form-field side has used sentence-case (`Model ratio`, `Cache ratio`, …) for some time, leaving two parallel translation entries per ratio type and causing the upstream sync UI to fall back to the English source string in zh/ja/ru/fr/vi. - Rewrite `RATIO_TYPE_OPTIONS` in `system-settings/models/constants.ts` and the conflict-detection labels in `upstream-ratio-sync.tsx` to reuse the sentence-case keys. - Drop the duplicate Title-Case entries from every locale and promote the better translations onto the surviving sentence-case keys (e.g. zh `Image ratio` keeps "图片倍率", `Audio completion ratio` keeps "音频补全倍率"). - Add a comment to `RATIO_TYPE_OPTIONS` warning future contributors not to switch back to Title Case without updating the catalogue. Note on backend fix4e93148d9- The backend portion of the merge (allocating a fresh map in `updateConfigFromMap` so removed keys are properly cleared) is already on HEAD; no additional change is needed. Verification - `bun run typecheck`: pass - `bun run lint`: pass - `bun run i18n:sync`: 0 missing / 0 extras across en / zh / fr / ja / ru / vi --------- Co-authored-by: Seefs <40468931+seefs001@users.noreply.github.com> Co-authored-by: Seefs <i@seefs.me> Co-authored-by: feitianbubu <feitianbubu@qq.com> Co-authored-by: Calcium-Ion <i@caion.me> Co-authored-by: Xyfacai <xyfacai@gmail.com> Co-authored-by: xiangsx <1984871009@qq.com> Co-authored-by: 郑伯涛 <351175318@qq.com> Co-authored-by: RedwindA <austinaosid@gmail.com> Co-authored-by: dean <1006393151@qq.com> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com> Co-authored-by: Bliod <bliod@bliod.lan> Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
3319 lines
100 KiB
React
Vendored
3319 lines
100 KiB
React
Vendored
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import i18next from 'i18next';
|
||
import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
|
||
import { copy, showSuccess } from './utils';
|
||
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
|
||
import {
|
||
BILLING_PRICING_VARS,
|
||
BILLING_VAR_KEY_TO_FIELD,
|
||
BILLING_VAR_REGEX,
|
||
} from '../constants';
|
||
import { visit } from 'unist-util-visit';
|
||
import * as LobeIcons from '@lobehub/icons';
|
||
import {
|
||
OpenAI,
|
||
Claude,
|
||
Gemini,
|
||
Moonshot,
|
||
Zhipu,
|
||
Qwen,
|
||
DeepSeek,
|
||
Minimax,
|
||
Wenxin,
|
||
Spark,
|
||
Midjourney,
|
||
Hunyuan,
|
||
Cohere,
|
||
Cloudflare,
|
||
Ai360,
|
||
Yi,
|
||
Jina,
|
||
Mistral,
|
||
XAI,
|
||
Ollama,
|
||
Doubao,
|
||
Suno,
|
||
Xinference,
|
||
OpenRouter,
|
||
Dify,
|
||
Coze,
|
||
SiliconCloud,
|
||
FastGPT,
|
||
Kling,
|
||
Jimeng,
|
||
Perplexity,
|
||
Replicate,
|
||
} from '@lobehub/icons';
|
||
|
||
import {
|
||
LayoutDashboard,
|
||
TerminalSquare,
|
||
MessageSquare,
|
||
Key,
|
||
BarChart3,
|
||
Image as ImageIcon,
|
||
CheckSquare,
|
||
CreditCard,
|
||
Layers,
|
||
Gift,
|
||
User,
|
||
Settings,
|
||
CircleUser,
|
||
Package,
|
||
Server,
|
||
CalendarClock,
|
||
} from 'lucide-react';
|
||
import {
|
||
SiAtlassian,
|
||
SiAuth0,
|
||
SiAuthentik,
|
||
SiBitbucket,
|
||
SiDiscord,
|
||
SiDropbox,
|
||
SiFacebook,
|
||
SiGitea,
|
||
SiGithub,
|
||
SiGitlab,
|
||
SiGoogle,
|
||
SiKeycloak,
|
||
SiLinkedin,
|
||
SiNextcloud,
|
||
SiNotion,
|
||
SiOkta,
|
||
SiOpenid,
|
||
SiReddit,
|
||
SiSlack,
|
||
SiTelegram,
|
||
SiTwitch,
|
||
SiWechat,
|
||
SiX,
|
||
} from 'react-icons/si';
|
||
|
||
// 获取侧边栏Lucide图标组件
|
||
export function getLucideIcon(key, selected = false) {
|
||
const size = 16;
|
||
const strokeWidth = 2;
|
||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
||
const iconColor = selected ? SELECTED_COLOR : 'currentColor';
|
||
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={iconColor} />;
|
||
case 'playground':
|
||
return <TerminalSquare {...commonProps} color={iconColor} />;
|
||
case 'chat':
|
||
return <MessageSquare {...commonProps} color={iconColor} />;
|
||
case 'token':
|
||
return <Key {...commonProps} color={iconColor} />;
|
||
case 'log':
|
||
return <BarChart3 {...commonProps} color={iconColor} />;
|
||
case 'midjourney':
|
||
return <ImageIcon {...commonProps} color={iconColor} />;
|
||
case 'task':
|
||
return <CheckSquare {...commonProps} color={iconColor} />;
|
||
case 'topup':
|
||
return <CreditCard {...commonProps} color={iconColor} />;
|
||
case 'channel':
|
||
return <Layers {...commonProps} color={iconColor} />;
|
||
case 'redemption':
|
||
return <Gift {...commonProps} color={iconColor} />;
|
||
case 'user':
|
||
case 'personal':
|
||
return <User {...commonProps} color={iconColor} />;
|
||
case 'models':
|
||
return <Package {...commonProps} color={iconColor} />;
|
||
case 'deployment':
|
||
return <Server {...commonProps} color={iconColor} />;
|
||
case 'subscription':
|
||
return <CalendarClock {...commonProps} color={iconColor} />;
|
||
case 'setting':
|
||
return <Settings {...commonProps} color={iconColor} />;
|
||
default:
|
||
return <CircleUser {...commonProps} color={iconColor} />;
|
||
}
|
||
}
|
||
|
||
// 获取模型分类
|
||
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-1') ||
|
||
model.model_name.toLowerCase().includes('text-embedding-3') ||
|
||
model.model_name.toLowerCase().includes('text-moderation') ||
|
||
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') ||
|
||
model.model_name.toLowerCase().includes('gemma') ||
|
||
model.model_name.toLowerCase().includes('learnlm') ||
|
||
model.model_name.toLowerCase().startsWith('embedding-') ||
|
||
model.model_name.toLowerCase().includes('text-embedding-004') ||
|
||
model.model_name.toLowerCase().includes('imagen-4') ||
|
||
model.model_name.toLowerCase().includes('veo-') ||
|
||
model.model_name.toLowerCase().includes('aqa'),
|
||
},
|
||
moonshot: {
|
||
label: 'Moonshot',
|
||
icon: <Moonshot />,
|
||
filter: (model) =>
|
||
model.model_name.toLowerCase().includes('moonshot') ||
|
||
model.model_name.toLowerCase().includes('kimi'),
|
||
},
|
||
zhipu: {
|
||
label: t('智谱'),
|
||
icon: <Zhipu.Color />,
|
||
filter: (model) =>
|
||
model.model_name.toLowerCase().includes('chatglm') ||
|
||
model.model_name.toLowerCase().includes('glm-') ||
|
||
model.model_name.toLowerCase().includes('cogview') ||
|
||
model.model_name.toLowerCase().includes('cogvideo'),
|
||
},
|
||
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') ||
|
||
model.model_name.toLowerCase().includes('minimax'),
|
||
},
|
||
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') ||
|
||
model.model_name.toLowerCase().includes('c4ai-') ||
|
||
model.model_name.toLowerCase().includes('embed-'),
|
||
},
|
||
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'),
|
||
},
|
||
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') ||
|
||
model.model_name.toLowerCase().includes('codestral') ||
|
||
model.model_name.toLowerCase().includes('pixtral') ||
|
||
model.model_name.toLowerCase().includes('voxtral') ||
|
||
model.model_name.toLowerCase().includes('magistral'),
|
||
},
|
||
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'),
|
||
},
|
||
yi: {
|
||
label: t('零一万物'),
|
||
icon: <Yi.Color />,
|
||
filter: (model) => model.model_name.toLowerCase().includes('yi'),
|
||
},
|
||
};
|
||
|
||
lastLocale = currentLocale;
|
||
return categoriesCache;
|
||
};
|
||
})();
|
||
|
||
/**
|
||
* 根据渠道类型返回对应的厂商图标
|
||
* @param {number} channelType - 渠道类型值
|
||
* @returns {JSX.Element|null} - 对应的厂商图标组件
|
||
*/
|
||
export function getChannelIcon(channelType) {
|
||
const iconSize = 14;
|
||
|
||
switch (channelType) {
|
||
case 1: // OpenAI
|
||
case 3: // Azure OpenAI
|
||
case 57: // Codex
|
||
return <OpenAI size={iconSize} />;
|
||
case 2: // Midjourney Proxy
|
||
case 5: // Midjourney Proxy Plus
|
||
return <Midjourney size={iconSize} />;
|
||
case 36: // Suno API
|
||
return <Suno size={iconSize} />;
|
||
case 4: // Ollama
|
||
return <Ollama size={iconSize} />;
|
||
case 14: // Anthropic Claude
|
||
case 33: // AWS Claude
|
||
return <Claude.Color size={iconSize} />;
|
||
case 41: // Vertex AI
|
||
return <Gemini.Color size={iconSize} />;
|
||
case 34: // Cohere
|
||
return <Cohere.Color size={iconSize} />;
|
||
case 39: // Cloudflare
|
||
return <Cloudflare.Color size={iconSize} />;
|
||
case 43: // DeepSeek
|
||
return <DeepSeek.Color size={iconSize} />;
|
||
case 15: // 百度文心千帆
|
||
case 46: // 百度文心千帆V2
|
||
return <Wenxin.Color size={iconSize} />;
|
||
case 17: // 阿里通义千问
|
||
return <Qwen.Color size={iconSize} />;
|
||
case 18: // 讯飞星火认知
|
||
return <Spark.Color size={iconSize} />;
|
||
case 16: // 智谱 ChatGLM
|
||
case 26: // 智谱 GLM-4V
|
||
return <Zhipu.Color size={iconSize} />;
|
||
case 24: // Google Gemini
|
||
case 11: // Google PaLM2
|
||
return <Gemini.Color size={iconSize} />;
|
||
case 47: // Xinference
|
||
return <Xinference.Color size={iconSize} />;
|
||
case 25: // Moonshot
|
||
return <Moonshot size={iconSize} />;
|
||
case 27: // Perplexity
|
||
return <Perplexity.Color size={iconSize} />;
|
||
case 20: // OpenRouter
|
||
return <OpenRouter size={iconSize} />;
|
||
case 19: // 360 智脑
|
||
return <Ai360.Color size={iconSize} />;
|
||
case 23: // 腾讯混元
|
||
return <Hunyuan.Color size={iconSize} />;
|
||
case 31: // 零一万物
|
||
return <Yi.Color size={iconSize} />;
|
||
case 35: // MiniMax
|
||
return <Minimax.Color size={iconSize} />;
|
||
case 37: // Dify
|
||
return <Dify.Color size={iconSize} />;
|
||
case 38: // Jina
|
||
return <Jina size={iconSize} />;
|
||
case 40: // SiliconCloud
|
||
return <SiliconCloud.Color size={iconSize} />;
|
||
case 42: // Mistral AI
|
||
return <Mistral.Color size={iconSize} />;
|
||
case 45: // 字节火山方舟、豆包通用
|
||
return <Doubao.Color size={iconSize} />;
|
||
case 48: // xAI
|
||
return <XAI size={iconSize} />;
|
||
case 49: // Coze
|
||
return <Coze size={iconSize} />;
|
||
case 50: // 可灵 Kling
|
||
return <Kling.Color size={iconSize} />;
|
||
case 51: // 即梦 Jimeng
|
||
return <Jimeng.Color size={iconSize} />;
|
||
case 54: // 豆包视频 Doubao Video
|
||
return <Doubao.Color size={iconSize} />;
|
||
case 56: // Replicate
|
||
return <Replicate size={iconSize} />;
|
||
case 8: // 自定义渠道
|
||
case 22: // 知识库:FastGPT
|
||
return <FastGPT.Color size={iconSize} />;
|
||
case 21: // 知识库:AI Proxy
|
||
case 44: // 嵌入模型:MokaAI M3E
|
||
default:
|
||
return null; // 未知类型或自定义渠道不显示图标
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据图标名称动态获取 LobeHub 图标组件
|
||
* 支持:
|
||
* - 基础:"OpenAI"、"OpenAI.Color" 等
|
||
* - 额外属性(点号链式):"OpenAI.Avatar.type={'platform'}"、"OpenRouter.Avatar.shape={'square'}"
|
||
* - 继续兼容第二参数 size;若字符串里有 size=,以字符串为准
|
||
* @param {string} iconName - 图标名称/描述
|
||
* @param {number} size - 图标大小,默认为 14
|
||
* @returns {JSX.Element} - 对应的图标组件或 Avatar
|
||
*/
|
||
export function getLobeHubIcon(iconName, size = 14) {
|
||
if (typeof iconName === 'string') iconName = iconName.trim();
|
||
// 如果没有图标名称,返回 Avatar
|
||
if (!iconName) {
|
||
return <Avatar size='extra-extra-small'>?</Avatar>;
|
||
}
|
||
|
||
// 解析组件路径与点号链式属性
|
||
const segments = String(iconName).split('.');
|
||
const baseKey = segments[0];
|
||
const BaseIcon = LobeIcons[baseKey];
|
||
|
||
let IconComponent = undefined;
|
||
let propStartIndex = 1;
|
||
|
||
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
|
||
IconComponent = BaseIcon[segments[1]];
|
||
propStartIndex = 2;
|
||
} else {
|
||
IconComponent = LobeIcons[baseKey];
|
||
propStartIndex = 1;
|
||
}
|
||
|
||
// 失败兜底
|
||
if (
|
||
!IconComponent ||
|
||
(typeof IconComponent !== 'function' && typeof IconComponent !== 'object')
|
||
) {
|
||
const firstLetter = String(iconName).charAt(0).toUpperCase();
|
||
return <Avatar size='extra-extra-small'>{firstLetter}</Avatar>;
|
||
}
|
||
|
||
// 解析点号链式属性,形如:key={...}、key='...'、key="..."、key=123、key、key=true/false
|
||
const props = {};
|
||
|
||
const parseValue = (raw) => {
|
||
if (raw == null) return true;
|
||
let v = String(raw).trim();
|
||
// 去除一层花括号包裹
|
||
if (v.startsWith('{') && v.endsWith('}')) {
|
||
v = v.slice(1, -1).trim();
|
||
}
|
||
// 去除引号
|
||
if (
|
||
(v.startsWith('"') && v.endsWith('"')) ||
|
||
(v.startsWith("'") && v.endsWith("'"))
|
||
) {
|
||
return v.slice(1, -1);
|
||
}
|
||
// 布尔
|
||
if (v === 'true') return true;
|
||
if (v === 'false') return false;
|
||
// 数字
|
||
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
|
||
// 其他原样返回字符串
|
||
return v;
|
||
};
|
||
|
||
for (let i = propStartIndex; i < segments.length; i++) {
|
||
const seg = segments[i];
|
||
if (!seg) continue;
|
||
const eqIdx = seg.indexOf('=');
|
||
if (eqIdx === -1) {
|
||
props[seg.trim()] = true;
|
||
continue;
|
||
}
|
||
const key = seg.slice(0, eqIdx).trim();
|
||
const valRaw = seg.slice(eqIdx + 1).trim();
|
||
props[key] = parseValue(valRaw);
|
||
}
|
||
|
||
// 兼容第二参数 size,若字符串中未显式指定 size,则使用函数入参
|
||
if (props.size == null && size != null) props.size = size;
|
||
|
||
return <IconComponent {...props} />;
|
||
}
|
||
|
||
const oauthProviderIconMap = {
|
||
github: SiGithub,
|
||
gitlab: SiGitlab,
|
||
gitea: SiGitea,
|
||
google: SiGoogle,
|
||
discord: SiDiscord,
|
||
facebook: SiFacebook,
|
||
linkedin: SiLinkedin,
|
||
x: SiX,
|
||
twitter: SiX,
|
||
slack: SiSlack,
|
||
telegram: SiTelegram,
|
||
wechat: SiWechat,
|
||
keycloak: SiKeycloak,
|
||
nextcloud: SiNextcloud,
|
||
authentik: SiAuthentik,
|
||
openid: SiOpenid,
|
||
okta: SiOkta,
|
||
auth0: SiAuth0,
|
||
atlassian: SiAtlassian,
|
||
bitbucket: SiBitbucket,
|
||
notion: SiNotion,
|
||
twitch: SiTwitch,
|
||
reddit: SiReddit,
|
||
dropbox: SiDropbox,
|
||
};
|
||
|
||
function isHttpUrl(value) {
|
||
return /^https?:\/\//i.test(value || '');
|
||
}
|
||
|
||
function isSimpleEmoji(value) {
|
||
if (!value) return false;
|
||
const trimmed = String(value).trim();
|
||
return trimmed.length > 0 && trimmed.length <= 4 && !isHttpUrl(trimmed);
|
||
}
|
||
|
||
function normalizeOAuthIconKey(raw) {
|
||
return raw
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/^ri:/, '')
|
||
.replace(/^react-icons:/, '')
|
||
.replace(/^si:/, '');
|
||
}
|
||
|
||
/**
|
||
* Render custom OAuth provider icon with react-icons or URL/emoji fallback.
|
||
* Supported formats:
|
||
* - react-icons simple key: github / gitlab / google / keycloak
|
||
* - prefixed key: ri:github / si:github
|
||
* - full URL image: https://example.com/logo.png
|
||
* - emoji: 🐱
|
||
*/
|
||
export function getOAuthProviderIcon(iconName, size = 20) {
|
||
const raw = String(iconName || '').trim();
|
||
const iconSize = Number(size) > 0 ? Number(size) : 20;
|
||
|
||
if (!raw) {
|
||
return <Layers size={iconSize} color='var(--semi-color-text-2)' />;
|
||
}
|
||
|
||
if (isHttpUrl(raw)) {
|
||
return (
|
||
<img
|
||
src={raw}
|
||
alt='provider icon'
|
||
width={iconSize}
|
||
height={iconSize}
|
||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (isSimpleEmoji(raw)) {
|
||
return (
|
||
<span
|
||
style={{
|
||
width: iconSize,
|
||
height: iconSize,
|
||
lineHeight: `${iconSize}px`,
|
||
textAlign: 'center',
|
||
display: 'inline-block',
|
||
fontSize: Math.max(Math.floor(iconSize * 0.8), 14),
|
||
}}
|
||
>
|
||
{raw}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const key = normalizeOAuthIconKey(raw);
|
||
const IconComp = oauthProviderIconMap[key];
|
||
if (IconComp) {
|
||
return <IconComp size={iconSize} />;
|
||
}
|
||
|
||
return (
|
||
<Avatar size='extra-extra-small'>{raw.charAt(0).toUpperCase()}</Avatar>
|
||
);
|
||
}
|
||
|
||
// 颜色列表
|
||
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)', // 浅橙色
|
||
};
|
||
|
||
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 = 'default',
|
||
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) {
|
||
return text.slice(0, limit - 3) + '...';
|
||
}
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* Render group tags based on the input group string
|
||
* @param {string} group - The input group string
|
||
* @returns {JSX.Element} - The rendered group tags
|
||
*/
|
||
export function renderGroup(group) {
|
||
if (group === '') {
|
||
return (
|
||
<Tag key='default' color='white' shape='circle'>
|
||
{i18next.t('用户分组')}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
const tagColors = {
|
||
vip: 'yellow',
|
||
pro: 'yellow',
|
||
svip: 'red',
|
||
premium: 'red',
|
||
};
|
||
|
||
const groups = group.split(',').sort();
|
||
|
||
return (
|
||
<span key={group}>
|
||
{groups.map((group) => (
|
||
<Tag
|
||
color={tagColors[group] || stringToColor(group)}
|
||
key={group}
|
||
shape='circle'
|
||
onClick={async (event) => {
|
||
event.stopPropagation();
|
||
if (await copy(group)) {
|
||
showSuccess(i18next.t('已复制:') + group);
|
||
} else {
|
||
Modal.error({
|
||
title: i18next.t('无法复制到剪贴板,请手动复制'),
|
||
content: group,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
{group}
|
||
</Tag>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function renderRatio(ratio) {
|
||
let color = 'green';
|
||
if (ratio > 5) {
|
||
color = 'red';
|
||
} else if (ratio > 3) {
|
||
color = 'orange';
|
||
} else if (ratio > 1) {
|
||
color = 'blue';
|
||
}
|
||
return (
|
||
<Tag color={color}>
|
||
{ratio}x {i18next.t('倍率')}
|
||
</Tag>
|
||
);
|
||
}
|
||
|
||
const measureTextWidth = (
|
||
text,
|
||
style = {
|
||
fontSize: '14px',
|
||
fontFamily:
|
||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||
},
|
||
containerWidth,
|
||
) => {
|
||
const span = document.createElement('span');
|
||
|
||
span.style.visibility = 'hidden';
|
||
span.style.position = 'absolute';
|
||
span.style.whiteSpace = 'nowrap';
|
||
span.style.fontSize = style.fontSize;
|
||
span.style.fontFamily = style.fontFamily;
|
||
|
||
span.textContent = text;
|
||
|
||
document.body.appendChild(span);
|
||
const width = span.offsetWidth;
|
||
|
||
document.body.removeChild(span);
|
||
|
||
return width;
|
||
};
|
||
|
||
export function truncateText(text, maxWidth = 200) {
|
||
const isMobileScreen = window.matchMedia(
|
||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||
).matches;
|
||
if (!isMobileScreen) {
|
||
return text;
|
||
}
|
||
if (!text) return text;
|
||
|
||
try {
|
||
// Handle percentage-based maxWidth
|
||
let actualMaxWidth = maxWidth;
|
||
if (typeof maxWidth === 'string' && maxWidth.endsWith('%')) {
|
||
const percentage = parseFloat(maxWidth) / 100;
|
||
// Use window width as fallback container width
|
||
actualMaxWidth = window.innerWidth * percentage;
|
||
}
|
||
|
||
const width = measureTextWidth(text);
|
||
if (width <= actualMaxWidth) return text;
|
||
|
||
let left = 0;
|
||
let right = text.length;
|
||
let result = text;
|
||
|
||
while (left <= right) {
|
||
const mid = Math.floor((left + right) / 2);
|
||
const truncated = text.slice(0, mid) + '...';
|
||
const currentWidth = measureTextWidth(truncated);
|
||
|
||
if (currentWidth <= actualMaxWidth) {
|
||
result = truncated;
|
||
left = mid + 1;
|
||
} else {
|
||
right = mid - 1;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
} catch (error) {
|
||
console.warn(
|
||
'Text measurement failed, falling back to character count',
|
||
error,
|
||
);
|
||
if (text.length > 20) {
|
||
return text.slice(0, 17) + '...';
|
||
}
|
||
return text;
|
||
}
|
||
}
|
||
|
||
export const renderGroupOption = (item) => {
|
||
const {
|
||
disabled,
|
||
selected,
|
||
label,
|
||
value,
|
||
focused,
|
||
className,
|
||
style,
|
||
onMouseEnter,
|
||
onClick,
|
||
empty,
|
||
emptyContent,
|
||
...rest
|
||
} = item;
|
||
|
||
const baseStyle = {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '8px 16px',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
backgroundColor: focused ? 'var(--semi-color-fill-0)' : 'transparent',
|
||
opacity: disabled ? 0.5 : 1,
|
||
...(selected && {
|
||
backgroundColor: 'var(--semi-color-primary-light-default)',
|
||
}),
|
||
'&:hover': {
|
||
backgroundColor: !disabled && 'var(--semi-color-fill-1)',
|
||
},
|
||
};
|
||
|
||
const handleClick = () => {
|
||
if (!disabled && onClick) {
|
||
onClick();
|
||
}
|
||
};
|
||
|
||
const handleMouseEnter = (e) => {
|
||
if (!disabled && onMouseEnter) {
|
||
onMouseEnter(e);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={baseStyle}
|
||
onClick={handleClick}
|
||
onMouseEnter={handleMouseEnter}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<Typography.Text strong type={disabled ? 'tertiary' : undefined}>
|
||
{value}
|
||
</Typography.Text>
|
||
<Typography.Text type='secondary' size='small'>
|
||
{label}
|
||
</Typography.Text>
|
||
</div>
|
||
{item.ratio && renderRatio(item.ratio)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export function renderNumber(num) {
|
||
if (num >= 1000000000) {
|
||
return (num / 1000000000).toFixed(1) + 'B';
|
||
} else if (num >= 1000000) {
|
||
return (num / 1000000).toFixed(1) + 'M';
|
||
} else if (num >= 10000) {
|
||
return (num / 1000).toFixed(1) + 'k';
|
||
} else {
|
||
return num;
|
||
}
|
||
}
|
||
|
||
export function renderQuotaNumberWithDigit(num, digits = 2) {
|
||
if (typeof num !== 'number' || isNaN(num)) {
|
||
return 0;
|
||
}
|
||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||
num = num.toFixed(digits);
|
||
if (quotaDisplayType === 'CNY') {
|
||
return '¥' + num;
|
||
} else if (quotaDisplayType === 'USD') {
|
||
return '$' + num;
|
||
} else if (quotaDisplayType === 'CUSTOM') {
|
||
const statusStr = localStorage.getItem('status');
|
||
let symbol = '¤';
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
symbol = s?.custom_currency_symbol || symbol;
|
||
}
|
||
} catch (e) {}
|
||
return symbol + num;
|
||
} else {
|
||
return num;
|
||
}
|
||
}
|
||
|
||
export function renderNumberWithPoint(num) {
|
||
if (num === undefined) return '';
|
||
num = num.toFixed(2);
|
||
if (num >= 100000) {
|
||
// Convert number to string to manipulate it
|
||
let numStr = num.toString();
|
||
// Find the position of the decimal point
|
||
let decimalPointIndex = numStr.indexOf('.');
|
||
|
||
let wholePart = numStr;
|
||
let decimalPart = '';
|
||
|
||
// If there is a decimal point, split the number into whole and decimal parts
|
||
if (decimalPointIndex !== -1) {
|
||
wholePart = numStr.slice(0, decimalPointIndex);
|
||
decimalPart = numStr.slice(decimalPointIndex);
|
||
}
|
||
|
||
// Take the first two and last two digits of the whole number part
|
||
let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
|
||
|
||
// Return the formatted number
|
||
return shortenedWholePart + decimalPart;
|
||
}
|
||
|
||
// If the number is less than 100,000, return it unmodified
|
||
return num;
|
||
}
|
||
|
||
export function getQuotaPerUnit() {
|
||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||
return quotaPerUnit;
|
||
}
|
||
|
||
export function renderUnitWithQuota(quota) {
|
||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||
quota = parseFloat(quota);
|
||
return quotaPerUnit * quota;
|
||
}
|
||
|
||
export function getQuotaWithUnit(quota, digits = 6) {
|
||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||
return (quota / quotaPerUnit).toFixed(digits);
|
||
}
|
||
|
||
export function renderQuotaWithAmount(amount) {
|
||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||
if (quotaDisplayType === 'TOKENS') {
|
||
return renderNumber(renderUnitWithQuota(amount));
|
||
}
|
||
|
||
const numericAmount = Number(amount);
|
||
const formattedAmount = Number.isFinite(numericAmount)
|
||
? numericAmount.toFixed(2)
|
||
: amount;
|
||
|
||
if (quotaDisplayType === 'CNY') {
|
||
return '¥' + formattedAmount;
|
||
} else if (quotaDisplayType === 'CUSTOM') {
|
||
const statusStr = localStorage.getItem('status');
|
||
let symbol = '¤';
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
symbol = s?.custom_currency_symbol || symbol;
|
||
}
|
||
} catch (e) {}
|
||
return symbol + formattedAmount;
|
||
}
|
||
return '$' + formattedAmount;
|
||
}
|
||
|
||
/**
|
||
* 获取当前货币配置信息
|
||
* @returns {Object} - { symbol, rate, type }
|
||
*/
|
||
export function getCurrencyConfig() {
|
||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||
const statusStr = localStorage.getItem('status');
|
||
|
||
let symbol = '$';
|
||
let rate = 1;
|
||
|
||
if (quotaDisplayType === 'CNY') {
|
||
symbol = '¥';
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
rate = s?.usd_exchange_rate || 7;
|
||
}
|
||
} catch (e) {}
|
||
} else if (quotaDisplayType === 'CUSTOM') {
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
symbol = s?.custom_currency_symbol || '¤';
|
||
rate = s?.custom_currency_exchange_rate || 1;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
return { symbol, rate, type: quotaDisplayType };
|
||
}
|
||
|
||
/**
|
||
* 将美元金额转换为当前选择的货币
|
||
* @param {number} usdAmount - 美元金额
|
||
* @param {number} digits - 小数位数
|
||
* @returns {string} - 格式化后的货币字符串
|
||
*/
|
||
export function convertUSDToCurrency(usdAmount, digits = 2) {
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
const convertedAmount = usdAmount * rate;
|
||
return symbol + convertedAmount.toFixed(digits);
|
||
}
|
||
|
||
export function renderQuota(quota, digits = 2) {
|
||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||
if (quotaDisplayType === 'TOKENS') {
|
||
return renderNumber(quota);
|
||
}
|
||
const resultUSD = quota / quotaPerUnit;
|
||
let symbol = '$';
|
||
let value = resultUSD;
|
||
if (quotaDisplayType === 'CNY') {
|
||
const statusStr = localStorage.getItem('status');
|
||
let usdRate = 1;
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
usdRate = s?.usd_exchange_rate || 1;
|
||
}
|
||
} catch (e) {}
|
||
value = resultUSD * usdRate;
|
||
symbol = '¥';
|
||
} else if (quotaDisplayType === 'CUSTOM') {
|
||
const statusStr = localStorage.getItem('status');
|
||
let symbolCustom = '¤';
|
||
let rate = 1;
|
||
try {
|
||
if (statusStr) {
|
||
const s = JSON.parse(statusStr);
|
||
symbolCustom = s?.custom_currency_symbol || symbolCustom;
|
||
rate = s?.custom_currency_exchange_rate || rate;
|
||
}
|
||
} catch (e) {}
|
||
value = resultUSD * rate;
|
||
symbol = symbolCustom;
|
||
}
|
||
const fixedResult = value.toFixed(digits);
|
||
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
|
||
const minValue = Math.pow(10, -digits);
|
||
return symbol + minValue.toFixed(digits);
|
||
}
|
||
return symbol + fixedResult;
|
||
}
|
||
|
||
function isValidGroupRatio(ratio) {
|
||
return Number.isFinite(ratio) && ratio !== -1;
|
||
}
|
||
|
||
/**
|
||
* Helper function to get effective ratio and label
|
||
* @param {number} groupRatio - The default group ratio
|
||
* @param {number} user_group_ratio - The user-specific group ratio
|
||
* @returns {Object} - Object containing { ratio, label, useUserGroupRatio }
|
||
*/
|
||
function getEffectiveRatio(groupRatio, user_group_ratio) {
|
||
const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
|
||
const ratioLabel = useUserGroupRatio
|
||
? i18next.t('专属倍率')
|
||
: i18next.t('分组倍率');
|
||
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||
|
||
return {
|
||
ratio: effectiveRatio,
|
||
label: ratioLabel,
|
||
useUserGroupRatio: useUserGroupRatio,
|
||
};
|
||
}
|
||
|
||
function getQuotaDisplayType() {
|
||
return localStorage.getItem('quota_display_type') || 'USD';
|
||
}
|
||
|
||
function resolveBillingDisplayMode(displayMode, modelPrice = -1) {
|
||
if (modelPrice !== -1) {
|
||
return 'price';
|
||
}
|
||
if (getQuotaDisplayType() === 'TOKENS') {
|
||
return 'ratio';
|
||
}
|
||
return displayMode === 'ratio' ? 'ratio' : 'price';
|
||
}
|
||
|
||
function isPriceDisplayMode(displayMode, modelPrice = -1) {
|
||
return resolveBillingDisplayMode(displayMode, modelPrice) === 'price';
|
||
}
|
||
|
||
function shouldUseRatioBillingProcess(modelPrice = -1) {
|
||
return modelPrice === -1 && getQuotaDisplayType() === 'TOKENS';
|
||
}
|
||
|
||
function formatCompactDisplayPrice(usdAmount, digits = 6) {
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
const amount = Number((usdAmount * rate).toFixed(digits));
|
||
return `${symbol}${amount}`;
|
||
}
|
||
|
||
function appendPricePart(parts, condition, key, vars) {
|
||
if (!condition) {
|
||
return;
|
||
}
|
||
parts.push(i18next.t(key, vars));
|
||
}
|
||
|
||
function joinBillingSummary(parts) {
|
||
return parts.filter(Boolean).join(',');
|
||
}
|
||
|
||
function getGroupRatioText(groupRatio, user_group_ratio) {
|
||
const { ratio, label } = getEffectiveRatio(groupRatio, user_group_ratio);
|
||
return i18next.t('{{ratioType}} {{ratio}}x', {
|
||
ratioType: label,
|
||
ratio,
|
||
});
|
||
}
|
||
|
||
function formatRatioValue(value, digits = 6) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) {
|
||
return 0;
|
||
}
|
||
return Number(num.toFixed(digits));
|
||
}
|
||
|
||
function renderDisplayAmountFromUsd(usdAmount, digits = 6) {
|
||
return renderQuotaWithAmount(Number(Number(usdAmount || 0).toFixed(digits)));
|
||
}
|
||
|
||
function formatBillingDisplayPrice(usdAmount, rate, digits = 6) {
|
||
return (usdAmount * rate).toFixed(digits);
|
||
}
|
||
|
||
function buildBillingText(key, vars) {
|
||
return i18next.t(key, vars);
|
||
}
|
||
|
||
function buildBillingPriceText(
|
||
key,
|
||
{ symbol, usdAmount, rate, amountKey = 'price', digits = 6, ...vars },
|
||
) {
|
||
return buildBillingText(key, {
|
||
symbol,
|
||
[amountKey]: formatBillingDisplayPrice(usdAmount, rate, digits),
|
||
...vars,
|
||
});
|
||
}
|
||
|
||
function renderBillingArticle(lines, { showReferenceNote = true } = {}) {
|
||
const articleLines = lines.filter(Boolean);
|
||
|
||
if (showReferenceNote) {
|
||
articleLines.push(buildBillingText('仅供参考,以实际扣费为准'));
|
||
}
|
||
|
||
return (
|
||
<article>
|
||
{articleLines.map((line, index) => (
|
||
<p key={index}>{line}</p>
|
||
))}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
// Shared core for simple price rendering (used by OpenAI-like and Claude-like variants)
|
||
function renderPriceSimpleCore({
|
||
modelRatio,
|
||
modelPrice = -1,
|
||
groupRatio,
|
||
user_group_ratio,
|
||
cacheTokens = 0,
|
||
cacheRatio = 1.0,
|
||
cacheCreationTokens = 0,
|
||
cacheCreationRatio = 1.0,
|
||
cacheCreationTokens5m = 0,
|
||
cacheCreationRatio5m = 1.0,
|
||
cacheCreationTokens1h = 0,
|
||
cacheCreationRatio1h = 1.0,
|
||
image = false,
|
||
imageRatio = 1.0,
|
||
isSystemPromptOverride = false,
|
||
displayMode = 'price',
|
||
outputMode = 'text',
|
||
}) {
|
||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||
groupRatio,
|
||
user_group_ratio,
|
||
);
|
||
const finalGroupRatio = effectiveGroupRatio;
|
||
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
const hasSplitCacheCreation =
|
||
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
|
||
const shouldShowLegacyCacheCreation =
|
||
!hasSplitCacheCreation && cacheCreationTokens !== 0;
|
||
|
||
const shouldShowCache = cacheTokens !== 0;
|
||
const shouldShowCacheCreation5m =
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0;
|
||
const shouldShowCacheCreation1h =
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0;
|
||
|
||
if (outputMode === 'segments') {
|
||
const segments = [
|
||
{
|
||
tone: 'primary',
|
||
text: getGroupRatioText(groupRatio, user_group_ratio),
|
||
},
|
||
];
|
||
|
||
if (modelPrice !== -1) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: isPriceDisplayMode(displayMode, modelPrice)
|
||
? i18next.t('模型价格 {{price}}', {
|
||
price: formatCompactDisplayPrice(modelPrice),
|
||
})
|
||
: i18next.t('按次'),
|
||
});
|
||
} else if (isPriceDisplayMode(displayMode, modelPrice)) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('输入 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0),
|
||
}),
|
||
});
|
||
|
||
if (shouldShowCache) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存读 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio),
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (hasSplitCacheCreation && shouldShowCacheCreation5m) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('5m缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio5m,
|
||
),
|
||
}),
|
||
});
|
||
}
|
||
if (hasSplitCacheCreation && shouldShowCacheCreation1h) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('1h缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio1h,
|
||
),
|
||
}),
|
||
});
|
||
}
|
||
if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio,
|
||
),
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (image) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('图片输入 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio),
|
||
}),
|
||
});
|
||
}
|
||
} else {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('模型: {{ratio}}', {
|
||
ratio: modelRatio,
|
||
}),
|
||
});
|
||
|
||
if (shouldShowCache) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存: {{cacheRatio}}', {
|
||
cacheRatio: cacheRatio,
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (hasSplitCacheCreation) {
|
||
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t(
|
||
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
|
||
{
|
||
cacheCreationRatio5m: cacheCreationRatio5m,
|
||
cacheCreationRatio1h: cacheCreationRatio1h,
|
||
},
|
||
),
|
||
});
|
||
} else if (shouldShowCacheCreation5m) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}', {
|
||
cacheCreationRatio5m: cacheCreationRatio5m,
|
||
}),
|
||
});
|
||
} else if (shouldShowCacheCreation1h) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存创建: 1h {{cacheCreationRatio1h}}', {
|
||
cacheCreationRatio1h: cacheCreationRatio1h,
|
||
}),
|
||
});
|
||
}
|
||
} else if (shouldShowLegacyCacheCreation) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('缓存创建: {{cacheCreationRatio}}', {
|
||
cacheCreationRatio: cacheCreationRatio,
|
||
}),
|
||
});
|
||
}
|
||
|
||
if (image) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('图片输入: {{imageRatio}}', {
|
||
imageRatio: imageRatio,
|
||
}),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (isSystemPromptOverride) {
|
||
segments.push({
|
||
tone: 'primary',
|
||
text: i18next.t('系统提示覆盖'),
|
||
});
|
||
}
|
||
|
||
return segments;
|
||
}
|
||
|
||
if (modelPrice !== -1) {
|
||
if (isPriceDisplayMode(displayMode, modelPrice)) {
|
||
return joinBillingSummary([
|
||
i18next.t('模型价格:{{symbol}}{{price}}', {
|
||
symbol: symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
}),
|
||
getGroupRatioText(groupRatio, user_group_ratio),
|
||
]);
|
||
}
|
||
const displayPrice = (modelPrice * rate).toFixed(6);
|
||
return i18next.t('价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}}', {
|
||
symbol: symbol,
|
||
price: displayPrice,
|
||
ratioType: ratioLabel,
|
||
ratio: finalGroupRatio,
|
||
});
|
||
}
|
||
|
||
if (isPriceDisplayMode(displayMode, modelPrice)) {
|
||
const parts = [];
|
||
if (modelPrice !== -1) {
|
||
parts.push(
|
||
i18next.t('模型价格 {{price}}', {
|
||
price: formatCompactDisplayPrice(modelPrice),
|
||
}),
|
||
);
|
||
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
|
||
return joinBillingSummary(parts);
|
||
}
|
||
|
||
parts.push(
|
||
i18next.t('输入 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0),
|
||
}),
|
||
);
|
||
|
||
if (shouldShowCache) {
|
||
parts.push(
|
||
i18next.t('缓存读 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0 * cacheRatio),
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (hasSplitCacheCreation && shouldShowCacheCreation5m) {
|
||
parts.push(
|
||
i18next.t('5m缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio5m,
|
||
),
|
||
}),
|
||
);
|
||
}
|
||
if (hasSplitCacheCreation && shouldShowCacheCreation1h) {
|
||
parts.push(
|
||
i18next.t('1h缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio1h,
|
||
),
|
||
}),
|
||
);
|
||
}
|
||
if (!hasSplitCacheCreation && shouldShowLegacyCacheCreation) {
|
||
parts.push(
|
||
i18next.t('缓存创建 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(
|
||
modelRatio * 2.0 * cacheCreationRatio,
|
||
),
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (image) {
|
||
parts.push(
|
||
i18next.t('图片输入 {{price}} / 1M tokens', {
|
||
price: formatCompactDisplayPrice(modelRatio * 2.0 * imageRatio),
|
||
}),
|
||
);
|
||
}
|
||
|
||
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
|
||
|
||
let result = joinBillingSummary(parts);
|
||
if (isSystemPromptOverride) {
|
||
result += '\n\r' + i18next.t('系统提示覆盖');
|
||
}
|
||
return result;
|
||
}
|
||
|
||
const parts = [];
|
||
// base: model ratio
|
||
parts.push(i18next.t('模型: {{ratio}}'));
|
||
|
||
// cache part (label differs when with image)
|
||
if (shouldShowCache) {
|
||
parts.push(i18next.t('缓存: {{cacheRatio}}'));
|
||
}
|
||
|
||
if (hasSplitCacheCreation) {
|
||
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
|
||
parts.push(
|
||
i18next.t(
|
||
'缓存创建: 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
|
||
),
|
||
);
|
||
} else if (shouldShowCacheCreation5m) {
|
||
parts.push(i18next.t('缓存创建: 5m {{cacheCreationRatio5m}}'));
|
||
} else if (shouldShowCacheCreation1h) {
|
||
parts.push(i18next.t('缓存创建: 1h {{cacheCreationRatio1h}}'));
|
||
}
|
||
} else if (shouldShowLegacyCacheCreation) {
|
||
parts.push(i18next.t('缓存创建: {{cacheCreationRatio}}'));
|
||
}
|
||
|
||
// image part
|
||
if (image) {
|
||
parts.push(i18next.t('图片输入: {{imageRatio}}'));
|
||
}
|
||
|
||
parts.push(`{{ratioType}}: {{groupRatio}}`);
|
||
|
||
let result = i18next.t(parts.join(' * '), {
|
||
ratio: modelRatio,
|
||
ratioType: ratioLabel,
|
||
groupRatio: finalGroupRatio,
|
||
cacheRatio: cacheRatio,
|
||
cacheCreationRatio: cacheCreationRatio,
|
||
cacheCreationRatio5m: cacheCreationRatio5m,
|
||
cacheCreationRatio1h: cacheCreationRatio1h,
|
||
imageRatio: imageRatio,
|
||
});
|
||
|
||
if (isSystemPromptOverride) {
|
||
result += '\n\r' + i18next.t('系统提示覆盖');
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
export function renderTaskBillingProcess(other, content) {
|
||
if (other?.task_id != null) {
|
||
return renderBillingArticle(
|
||
[content].filter(Boolean),
|
||
{ showReferenceNote: false },
|
||
);
|
||
}
|
||
return renderBillingArticle([
|
||
buildBillingText('任务预扣费(将在任务完成后按实际token重算)'),
|
||
]);
|
||
}
|
||
|
||
export function renderModelPrice(opts) {
|
||
const {
|
||
prompt_tokens: inputTokens = 0,
|
||
completion_tokens: completionTokens = 0,
|
||
model_ratio: modelRatio = 0,
|
||
model_price: modelPrice = -1,
|
||
completion_ratio: _completionRatio,
|
||
group_ratio: _groupRatio,
|
||
user_group_ratio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
image = false,
|
||
image_ratio: imageRatio = 1.0,
|
||
image_output: imageOutputTokens = 0,
|
||
web_search: webSearch = false,
|
||
web_search_call_count: webSearchCallCount = 0,
|
||
web_search_price: webSearchPrice = 0,
|
||
file_search: fileSearch = false,
|
||
file_search_call_count: fileSearchCallCount = 0,
|
||
file_search_price: fileSearchPrice = 0,
|
||
audio_input_seperate_price: audioInputSeperatePrice = false,
|
||
audio_input_token_count: audioInputTokens = 0,
|
||
audio_input_price: audioInputPrice = 0,
|
||
image_generation_call: imageGenerationCall = false,
|
||
image_generation_call_price: imageGenerationCallPrice = 0,
|
||
displayMode = 'price',
|
||
} = opts;
|
||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||
_groupRatio,
|
||
user_group_ratio,
|
||
);
|
||
let groupRatio = effectiveGroupRatio;
|
||
const completionRatio = _completionRatio ?? 0;
|
||
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
|
||
if (!shouldUseRatioBillingProcess(modelPrice)) {
|
||
if (modelPrice !== -1) {
|
||
return renderBillingArticle([
|
||
buildBillingPriceText('按次:{{symbol}}{{price}}', {
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText(
|
||
'按次 {{symbol}}{{price}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amountKey: 'price',
|
||
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
|
||
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
|
||
const imageRatioPrice = modelRatio * 2.0 * imageRatio;
|
||
let effectiveInputTokens =
|
||
inputTokens - cacheTokens + cacheTokens * cacheRatio;
|
||
if (image && imageOutputTokens > 0) {
|
||
effectiveInputTokens =
|
||
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
|
||
}
|
||
if (audioInputTokens > 0) {
|
||
effectiveInputTokens -= audioInputTokens;
|
||
}
|
||
const price =
|
||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||
imageGenerationCallPrice * groupRatio;
|
||
|
||
let inputDesc = '';
|
||
if (image && imageOutputTokens > 0) {
|
||
inputDesc = buildBillingPriceText(
|
||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
nonImageInput: inputTokens - imageOutputTokens,
|
||
imageInput: imageOutputTokens,
|
||
symbol,
|
||
usdAmount: inputRatioPrice,
|
||
rate,
|
||
},
|
||
);
|
||
} else if (cacheTokens > 0) {
|
||
inputDesc = buildBillingText(
|
||
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
|
||
{
|
||
nonCacheInput: inputTokens - cacheTokens,
|
||
cacheInput: cacheTokens,
|
||
symbol,
|
||
price: formatBillingDisplayPrice(inputRatioPrice, rate),
|
||
cachePrice: formatBillingDisplayPrice(cacheRatioPrice, rate),
|
||
},
|
||
);
|
||
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
|
||
inputDesc = buildBillingText(
|
||
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
|
||
{
|
||
nonAudioInput: inputTokens - audioInputTokens,
|
||
audioInput: audioInputTokens,
|
||
symbol,
|
||
price: formatBillingDisplayPrice(inputRatioPrice, rate),
|
||
audioPrice: formatBillingDisplayPrice(audioInputPrice, rate),
|
||
},
|
||
);
|
||
} else {
|
||
inputDesc = buildBillingPriceText(
|
||
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
input: inputTokens,
|
||
symbol,
|
||
usdAmount: inputRatioPrice,
|
||
rate,
|
||
},
|
||
);
|
||
}
|
||
|
||
const outputDesc = buildBillingText(
|
||
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
|
||
{
|
||
completion: completionTokens,
|
||
symbol,
|
||
compPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
|
||
ratio: groupRatio,
|
||
ratioType: ratioLabel,
|
||
},
|
||
);
|
||
|
||
const extraServices = [
|
||
webSearch && webSearchCallCount > 0
|
||
? buildBillingPriceText(
|
||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||
{
|
||
count: webSearchCallCount,
|
||
symbol,
|
||
usdAmount: webSearchPrice,
|
||
rate,
|
||
ratio: groupRatio,
|
||
ratioType: ratioLabel,
|
||
},
|
||
)
|
||
: '',
|
||
fileSearch && fileSearchCallCount > 0
|
||
? buildBillingPriceText(
|
||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||
{
|
||
count: fileSearchCallCount,
|
||
symbol,
|
||
usdAmount: fileSearchPrice,
|
||
rate,
|
||
ratio: groupRatio,
|
||
ratioType: ratioLabel,
|
||
},
|
||
)
|
||
: '',
|
||
imageGenerationCall && imageGenerationCallPrice > 0
|
||
? buildBillingPriceText(
|
||
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||
{
|
||
symbol,
|
||
usdAmount: imageGenerationCallPrice,
|
||
rate,
|
||
ratio: groupRatio,
|
||
ratioType: ratioLabel,
|
||
},
|
||
)
|
||
: '',
|
||
].join('');
|
||
|
||
const billingLines = [
|
||
buildBillingPriceText(
|
||
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
|
||
{
|
||
symbol,
|
||
usdAmount: inputRatioPrice,
|
||
rate,
|
||
audioPrice: audioInputSeperatePrice
|
||
? `,${i18next.t('音频输入价格')} ${symbol}${formatBillingDisplayPrice(audioInputPrice, rate)} / 1M tokens`
|
||
: '',
|
||
},
|
||
),
|
||
buildBillingPriceText('输出价格:{{symbol}}{{total}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: completionRatioPrice,
|
||
rate,
|
||
amountKey: 'total',
|
||
}),
|
||
cacheTokens > 0
|
||
? buildBillingPriceText(
|
||
'缓存读取价格:{{symbol}}{{total}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: inputRatioPrice * cacheRatio,
|
||
rate,
|
||
amountKey: 'total',
|
||
},
|
||
)
|
||
: null,
|
||
image && imageOutputTokens > 0
|
||
? buildBillingPriceText(
|
||
'图片输入价格:{{symbol}}{{total}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: imageRatioPrice,
|
||
rate,
|
||
amountKey: 'total',
|
||
},
|
||
)
|
||
: null,
|
||
webSearch && webSearchCallCount > 0
|
||
? buildBillingPriceText('Web搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||
symbol,
|
||
usdAmount: webSearchPrice,
|
||
rate,
|
||
})
|
||
: null,
|
||
fileSearch && fileSearchCallCount > 0
|
||
? buildBillingPriceText('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||
symbol,
|
||
usdAmount: fileSearchPrice,
|
||
rate,
|
||
})
|
||
: null,
|
||
imageGenerationCall && imageGenerationCallPrice > 0
|
||
? buildBillingPriceText('图片生成调用:{{symbol}}{{price}} / 1次', {
|
||
symbol,
|
||
usdAmount: imageGenerationCallPrice,
|
||
rate,
|
||
})
|
||
: null,
|
||
buildBillingText(
|
||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
|
||
{
|
||
inputDesc,
|
||
outputDesc,
|
||
extraServices,
|
||
symbol,
|
||
total: formatBillingDisplayPrice(price, rate),
|
||
},
|
||
),
|
||
];
|
||
|
||
return renderBillingArticle(billingLines);
|
||
}
|
||
|
||
if (modelPrice !== -1) {
|
||
const displayPrice = (modelPrice * rate).toFixed(6);
|
||
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
|
||
return i18next.t(
|
||
'按次:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol: symbol,
|
||
price: displayPrice,
|
||
ratio: groupRatio,
|
||
total: displayTotal,
|
||
ratioType: ratioLabel,
|
||
},
|
||
);
|
||
}
|
||
|
||
const modelRatioValue = formatRatioValue(modelRatio);
|
||
const completionRatioValue = formatRatioValue(completionRatio);
|
||
const cacheRatioValue = formatRatioValue(cacheRatio);
|
||
const imageRatioValue = formatRatioValue(imageRatio);
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
|
||
const audioRatioValue =
|
||
audioInputSeperatePrice && audioInputPrice > 0
|
||
? formatRatioValue(audioInputPrice / inputRatioPrice)
|
||
: null;
|
||
|
||
const textInputTokens = Math.max(
|
||
inputTokens - cacheTokens - audioInputTokens,
|
||
0,
|
||
);
|
||
const imageInputTokens =
|
||
image && imageOutputTokens > 0 ? imageOutputTokens : 0;
|
||
const cacheInputTokens = cacheTokens;
|
||
|
||
const textInputAmount =
|
||
(textInputTokens / 1000000) * inputRatioPrice * groupRatio;
|
||
const cacheInputAmount =
|
||
(cacheInputTokens / 1000000) *
|
||
inputRatioPrice *
|
||
cacheRatioValue *
|
||
groupRatio;
|
||
const imageInputAmount =
|
||
(imageInputTokens / 1000000) *
|
||
inputRatioPrice *
|
||
imageRatioValue *
|
||
groupRatio;
|
||
const audioInputAmount =
|
||
(audioInputTokens / 1000000) * audioInputPrice * groupRatio;
|
||
const completionAmount =
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
||
const webSearchAmount =
|
||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio;
|
||
const fileSearchAmount =
|
||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
|
||
const imageGenerationAmount = imageGenerationCallPrice * groupRatio;
|
||
|
||
const totalAmount =
|
||
textInputAmount +
|
||
cacheInputAmount +
|
||
imageInputAmount +
|
||
audioInputAmount +
|
||
completionAmount +
|
||
webSearchAmount +
|
||
fileSearchAmount +
|
||
imageGenerationAmount;
|
||
|
||
return renderBillingArticle([
|
||
[
|
||
buildBillingText('模型倍率 {{modelRatio}}', {
|
||
modelRatio: modelRatioValue,
|
||
}),
|
||
buildBillingText('补全倍率 {{completionRatio}}', {
|
||
completionRatio: completionRatioValue,
|
||
}),
|
||
cacheInputTokens > 0
|
||
? buildBillingText('缓存倍率 {{cacheRatio}}', {
|
||
cacheRatio: cacheRatioValue,
|
||
})
|
||
: null,
|
||
imageInputTokens > 0
|
||
? buildBillingText('图片倍率 {{imageRatio}}', {
|
||
imageRatio: imageRatioValue,
|
||
})
|
||
: null,
|
||
audioRatioValue !== null
|
||
? buildBillingText('音频倍率 {{audioRatio}}', {
|
||
audioRatio: audioRatioValue,
|
||
})
|
||
: null,
|
||
buildBillingText('{{ratioType}} {{ratio}}', {
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
}),
|
||
]
|
||
.filter(Boolean)
|
||
.join(','),
|
||
textInputTokens > 0
|
||
? buildBillingText(
|
||
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: textInputTokens,
|
||
modelRatio: modelRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(textInputAmount),
|
||
},
|
||
)
|
||
: null,
|
||
cacheInputTokens > 0
|
||
? buildBillingText(
|
||
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheInputTokens,
|
||
modelRatio: modelRatioValue,
|
||
cacheRatio: cacheRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(cacheInputAmount),
|
||
},
|
||
)
|
||
: null,
|
||
imageInputTokens > 0
|
||
? buildBillingText(
|
||
'图片输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 图片倍率 {{imageRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: imageInputTokens,
|
||
modelRatio: modelRatioValue,
|
||
imageRatio: imageRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(imageInputAmount),
|
||
},
|
||
)
|
||
: null,
|
||
audioInputTokens > 0 && audioRatioValue !== null
|
||
? buildBillingText(
|
||
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: audioInputTokens,
|
||
modelRatio: modelRatioValue,
|
||
audioRatio: audioRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(audioInputAmount),
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingText(
|
||
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: completionTokens,
|
||
modelRatio: modelRatioValue,
|
||
completionRatio: completionRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(completionAmount),
|
||
},
|
||
),
|
||
webSearch && webSearchCallCount > 0
|
||
? buildBillingText(
|
||
'Web 搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
count: webSearchCallCount,
|
||
price: renderDisplayAmountFromUsd(webSearchPrice),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(webSearchAmount),
|
||
},
|
||
)
|
||
: null,
|
||
fileSearch && fileSearchCallCount > 0
|
||
? buildBillingText(
|
||
'文件搜索:{{count}} / 1K * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
count: fileSearchCallCount,
|
||
price: renderDisplayAmountFromUsd(fileSearchPrice),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(fileSearchAmount),
|
||
},
|
||
)
|
||
: null,
|
||
imageGenerationCall && imageGenerationCallPrice > 0
|
||
? buildBillingText(
|
||
'图片生成:1 次 * 单价 {{price}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
price: renderDisplayAmountFromUsd(imageGenerationCallPrice),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(imageGenerationAmount),
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingText('合计:{{total}}', {
|
||
total: renderDisplayAmountFromUsd(totalAmount),
|
||
}),
|
||
]);
|
||
}
|
||
|
||
export function renderLogContent(opts) {
|
||
const {
|
||
model_ratio: modelRatio,
|
||
completion_ratio: completionRatio,
|
||
model_price: modelPrice = -1,
|
||
group_ratio: groupRatio,
|
||
user_group_ratio,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
image = false,
|
||
image_ratio: imageRatio = 1.0,
|
||
web_search: webSearch = false,
|
||
web_search_call_count: webSearchCallCount = 0,
|
||
file_search: fileSearch = false,
|
||
file_search_call_count: fileSearchCallCount = 0,
|
||
displayMode = 'price',
|
||
} = opts;
|
||
const {
|
||
ratio,
|
||
label: ratioLabel,
|
||
useUserGroupRatio: useUserGroupRatio,
|
||
} = getEffectiveRatio(groupRatio, user_group_ratio);
|
||
|
||
// 获取货币配置
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
|
||
if (isPriceDisplayMode(displayMode, modelPrice)) {
|
||
if (modelPrice !== -1) {
|
||
return joinBillingSummary([
|
||
i18next.t('模型价格 {{symbol}}{{price}} / 次', {
|
||
symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
}),
|
||
getGroupRatioText(groupRatio, user_group_ratio),
|
||
]);
|
||
}
|
||
|
||
const parts = [
|
||
i18next.t('输入价格 {{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
price: (modelRatio * 2.0 * rate).toFixed(6),
|
||
}),
|
||
i18next.t('输出价格 {{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
price: (modelRatio * 2.0 * completionRatio * rate).toFixed(6),
|
||
}),
|
||
];
|
||
appendPricePart(
|
||
parts,
|
||
cacheRatio !== 1.0,
|
||
'缓存读取价格 {{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6),
|
||
},
|
||
);
|
||
appendPricePart(
|
||
parts,
|
||
image,
|
||
'图片输入价格 {{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
price: (modelRatio * 2.0 * imageRatio * rate).toFixed(6),
|
||
},
|
||
);
|
||
appendPricePart(
|
||
parts,
|
||
webSearch,
|
||
'Web 搜索调用 {{webSearchCallCount}} 次',
|
||
{
|
||
webSearchCallCount,
|
||
},
|
||
);
|
||
appendPricePart(
|
||
parts,
|
||
fileSearch,
|
||
'文件搜索调用 {{fileSearchCallCount}} 次',
|
||
{
|
||
fileSearchCallCount,
|
||
},
|
||
);
|
||
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
|
||
return joinBillingSummary(parts);
|
||
}
|
||
|
||
if (modelPrice !== -1) {
|
||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||
symbol: symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
ratioType: ratioLabel,
|
||
ratio,
|
||
});
|
||
} else {
|
||
if (image) {
|
||
return i18next.t(
|
||
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}',
|
||
{
|
||
modelRatio: modelRatio,
|
||
cacheRatio: cacheRatio,
|
||
completionRatio: completionRatio,
|
||
imageRatio: imageRatio,
|
||
ratioType: ratioLabel,
|
||
ratio,
|
||
},
|
||
);
|
||
} else if (webSearch) {
|
||
return i18next.t(
|
||
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}},Web 搜索调用 {{webSearchCallCount}} 次',
|
||
{
|
||
modelRatio: modelRatio,
|
||
cacheRatio: cacheRatio,
|
||
completionRatio: completionRatio,
|
||
ratioType: ratioLabel,
|
||
ratio,
|
||
webSearchCallCount,
|
||
},
|
||
);
|
||
} else {
|
||
return i18next.t(
|
||
'模型倍率 {{modelRatio}},缓存倍率 {{cacheRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
|
||
{
|
||
modelRatio: modelRatio,
|
||
cacheRatio: cacheRatio,
|
||
completionRatio: completionRatio,
|
||
ratioType: ratioLabel,
|
||
ratio,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function stripExprVersion(exprStr) {
|
||
if (!exprStr) return { version: 1, body: '' };
|
||
const m = exprStr.match(/^v(\d+):([\s\S]*)$/);
|
||
if (m) return { version: Number(m[1]), body: m[2] };
|
||
return { version: 1, body: exprStr };
|
||
}
|
||
|
||
function parseTierBody(bodyStr) {
|
||
const coeffs = {};
|
||
const re = new RegExp(BILLING_VAR_REGEX.source, 'g');
|
||
let m;
|
||
while ((m = re.exec(bodyStr)) !== null) {
|
||
if (!(m[1] in coeffs)) coeffs[m[1]] = Number(m[2]);
|
||
}
|
||
const tier = {};
|
||
for (const [varName, field] of Object.entries(BILLING_VAR_KEY_TO_FIELD)) {
|
||
tier[field] = coeffs[varName] || 0;
|
||
}
|
||
return tier;
|
||
}
|
||
|
||
export function parseTiersFromExpr(exprStr) {
|
||
if (!exprStr) return [];
|
||
try {
|
||
const { body } = stripExprVersion(exprStr);
|
||
const condGroup = `((?:(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c|len)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`;
|
||
const tierRe = new RegExp(`(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*([^)]+)\\)`, 'g');
|
||
const tiers = [];
|
||
let m;
|
||
while ((m = tierRe.exec(body)) !== null) {
|
||
const condStr = m[1] || '';
|
||
const conditions = [];
|
||
if (condStr) {
|
||
for (const cp of condStr.split(/\s*&&\s*/)) {
|
||
const cm = cp.trim().match(/^(p|c|len)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/);
|
||
if (cm) conditions.push({ var: cm[1], op: cm[2], value: Number(cm[3]) });
|
||
}
|
||
}
|
||
const tier = parseTierBody(m[3]);
|
||
tier.label = m[2];
|
||
tier.conditions = conditions;
|
||
tiers.push(tier);
|
||
}
|
||
return tiers;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function renderTieredModelPrice(opts) {
|
||
const {
|
||
prompt_tokens: inputTokens = 0,
|
||
completion_tokens: completionTokens = 0,
|
||
expr_b64: exprB64,
|
||
matched_tier: matchedTier,
|
||
group_ratio: groupRatio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_creation_tokens: cacheCreationTokens = 0,
|
||
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||
} = opts;
|
||
let exprStr = '';
|
||
try { exprStr = atob(exprB64); } catch { /* ignore */ }
|
||
const tiers = parseTiersFromExpr(exprStr);
|
||
if (tiers.length === 0) {
|
||
return i18next.t('阶梯计费(表达式解析失败)');
|
||
}
|
||
|
||
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
const gr = groupRatio || 1;
|
||
|
||
const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0
|
||
|| cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
|
||
const priceLines = BILLING_PRICING_VARS
|
||
.filter((v) => v.group !== 'cache' || hasAnyCacheTokens)
|
||
.map((v) => [v.field, v.label]);
|
||
|
||
const lines = [
|
||
buildBillingText('命中档位:{{tier}}', { tier: matchedTier || tier.label }),
|
||
...priceLines
|
||
.filter(([field]) => tier[field] > 0)
|
||
.map(([field, label]) =>
|
||
buildBillingPriceText(`${label}:{{symbol}}{{price}} / 1M tokens`, { symbol, usdAmount: tier[field], rate }),
|
||
),
|
||
];
|
||
|
||
return renderBillingArticle(lines);
|
||
}
|
||
|
||
export function renderTieredModelPriceSimple(opts) {
|
||
const {
|
||
expr_b64: exprB64,
|
||
matched_tier: matchedTier,
|
||
group_ratio: groupRatio,
|
||
user_group_ratio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||
cache_creation_tokens: cacheCreationTokens = 0,
|
||
displayMode = 'price',
|
||
outputMode = 'segments',
|
||
} = opts;
|
||
let exprStr = '';
|
||
try { exprStr = atob(exprB64); } catch { /* ignore */ }
|
||
const tiers = parseTiersFromExpr(exprStr);
|
||
const tier = tiers.find((t) => t.label === matchedTier) || tiers[0];
|
||
|
||
if (outputMode === 'segments') {
|
||
const segments = [
|
||
{
|
||
tone: 'primary',
|
||
text: getGroupRatioText(groupRatio, user_group_ratio),
|
||
},
|
||
];
|
||
|
||
if (tier && isPriceDisplayMode(displayMode)) {
|
||
const hasAnyCacheTokens = cacheTokens > 0 || cacheCreationTokens > 0
|
||
|| cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
const priceSegments = BILLING_PRICING_VARS
|
||
.filter((v) => v.group !== 'cache' || hasAnyCacheTokens)
|
||
.map((v) => [v.field, v.shortLabel]);
|
||
for (const [field, label] of priceSegments) {
|
||
if (tier[field] > 0) {
|
||
segments.push({
|
||
tone: 'secondary',
|
||
text: i18next.t('{{label}} {{price}} / 1M tokens', {
|
||
label: i18next.t(label),
|
||
price: formatCompactDisplayPrice(tier[field]),
|
||
}),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return segments;
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
export function renderModelPriceSimple(opts) {
|
||
const {
|
||
model_ratio: modelRatio,
|
||
model_price: modelPrice = -1,
|
||
group_ratio: groupRatio,
|
||
user_group_ratio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
cache_creation_tokens: cacheCreationTokens = 0,
|
||
cache_creation_ratio: cacheCreationRatio = 1.0,
|
||
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
|
||
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
|
||
image = false,
|
||
image_ratio: imageRatio = 1.0,
|
||
is_system_prompt_overwritten: isSystemPromptOverride = false,
|
||
provider = 'openai',
|
||
displayMode = 'price',
|
||
outputMode = 'text',
|
||
} = opts;
|
||
return renderPriceSimpleCore({
|
||
modelRatio,
|
||
modelPrice,
|
||
groupRatio,
|
||
user_group_ratio,
|
||
cacheTokens,
|
||
cacheRatio,
|
||
cacheCreationTokens,
|
||
cacheCreationRatio,
|
||
cacheCreationTokens5m,
|
||
cacheCreationRatio5m,
|
||
cacheCreationTokens1h,
|
||
cacheCreationRatio1h,
|
||
image,
|
||
imageRatio,
|
||
isSystemPromptOverride,
|
||
displayMode,
|
||
outputMode,
|
||
});
|
||
}
|
||
|
||
export function renderAudioModelPrice(opts) {
|
||
const {
|
||
prompt_tokens: inputTokens = 0,
|
||
completion_tokens: completionTokens = 0,
|
||
model_ratio: modelRatio = 0,
|
||
model_price: modelPrice = -1,
|
||
completion_ratio: _completionRatio,
|
||
audio_input: audioInputTokens = 0,
|
||
audio_output: audioCompletionTokens = 0,
|
||
audio_ratio: _audioRatio,
|
||
audio_completion_ratio: _audioCompletionRatio,
|
||
group_ratio: _groupRatio,
|
||
user_group_ratio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
displayMode = 'price',
|
||
} = opts;
|
||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||
_groupRatio,
|
||
user_group_ratio,
|
||
);
|
||
let groupRatio = effectiveGroupRatio;
|
||
const completionRatio = _completionRatio ?? 0;
|
||
const audioRatio = parseFloat(_audioRatio ?? 0).toFixed(6);
|
||
const audioCompletionRatio = _audioCompletionRatio ?? 0;
|
||
|
||
// 获取货币配置
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
|
||
if (!shouldUseRatioBillingProcess(modelPrice)) {
|
||
if (modelPrice !== -1) {
|
||
return renderBillingArticle([
|
||
buildBillingPriceText('模型价格:{{symbol}}{{price}} / 次', {
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText(
|
||
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
|
||
const textPrice =
|
||
((inputTokens - cacheTokens + cacheTokens * cacheRatio) / 1000000) *
|
||
inputRatioPrice *
|
||
groupRatio +
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
||
const audioPrice =
|
||
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
|
||
(audioCompletionTokens / 1000000) *
|
||
inputRatioPrice *
|
||
audioRatio *
|
||
audioCompletionRatio *
|
||
groupRatio;
|
||
const totalPrice = textPrice + audioPrice;
|
||
|
||
return renderBillingArticle([
|
||
buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: inputRatioPrice,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: completionRatioPrice,
|
||
rate,
|
||
}),
|
||
cacheTokens > 0
|
||
? buildBillingPriceText(
|
||
'缓存读取价格:{{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: inputRatioPrice * cacheRatio,
|
||
rate,
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingPriceText('音频输入价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: inputRatioPrice * audioRatio,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText('音频补全价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: inputRatioPrice * audioRatio * audioCompletionRatio,
|
||
rate,
|
||
}),
|
||
buildBillingText(
|
||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{textInputPrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{textCompPrice}} + 音频提示 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{audioCompletion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
input: inputTokens,
|
||
completion: completionTokens,
|
||
audioInput: audioInputTokens,
|
||
audioCompletion: audioCompletionTokens,
|
||
textInputPrice: formatBillingDisplayPrice(inputRatioPrice, rate),
|
||
textCompPrice: formatBillingDisplayPrice(completionRatioPrice, rate),
|
||
audioInputPrice: formatBillingDisplayPrice(
|
||
audioRatio * inputRatioPrice,
|
||
rate,
|
||
),
|
||
audioCompPrice: formatBillingDisplayPrice(
|
||
audioRatio * audioCompletionRatio * inputRatioPrice,
|
||
rate,
|
||
),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
symbol,
|
||
total: formatBillingDisplayPrice(totalPrice, rate),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
// 1 ratio = $0.002 / 1K tokens
|
||
if (modelPrice !== -1) {
|
||
return i18next.t(
|
||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol: symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
ratio: groupRatio,
|
||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||
ratioType: ratioLabel,
|
||
},
|
||
);
|
||
}
|
||
|
||
const modelRatioValue = formatRatioValue(modelRatio);
|
||
const completionRatioValue = formatRatioValue(completionRatio);
|
||
const cacheRatioValue = formatRatioValue(cacheRatio);
|
||
const audioRatioValue = formatRatioValue(audioRatio);
|
||
const audioCompletionRatioValue = formatRatioValue(audioCompletionRatio);
|
||
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
|
||
|
||
const effectiveInputTokens =
|
||
inputTokens - cacheTokens + cacheTokens * cacheRatioValue;
|
||
|
||
const textPrice =
|
||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
||
const audioPrice =
|
||
(audioInputTokens / 1000000) *
|
||
inputRatioPrice *
|
||
audioRatioValue *
|
||
groupRatio +
|
||
(audioCompletionTokens / 1000000) *
|
||
inputRatioPrice *
|
||
audioRatioValue *
|
||
audioCompletionRatioValue *
|
||
groupRatio;
|
||
const totalPrice = textPrice + audioPrice;
|
||
|
||
return renderBillingArticle([
|
||
buildBillingText(
|
||
'模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},音频倍率 {{audioRatio}},音频补全倍率 {{audioCompletionRatio}},{{cachePart}}{{ratioType}} {{ratio}}',
|
||
{
|
||
modelRatio: modelRatioValue,
|
||
completionRatio: completionRatioValue,
|
||
audioRatio: audioRatioValue,
|
||
audioCompletionRatio: audioCompletionRatioValue,
|
||
cachePart:
|
||
cacheTokens > 0
|
||
? `${i18next.t('缓存倍率')} ${cacheRatioValue},`
|
||
: '',
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
},
|
||
),
|
||
buildBillingText(
|
||
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: Math.max(inputTokens - cacheTokens, 0),
|
||
modelRatio: modelRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(Math.max(inputTokens - cacheTokens, 0) / 1000000) *
|
||
inputRatioPrice *
|
||
groupRatio,
|
||
),
|
||
},
|
||
),
|
||
cacheTokens > 0
|
||
? buildBillingText(
|
||
'缓存输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheTokens,
|
||
modelRatio: modelRatioValue,
|
||
cacheRatio: cacheRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(cacheTokens / 1000000) *
|
||
inputRatioPrice *
|
||
cacheRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingText(
|
||
'文字输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 补全倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: completionTokens,
|
||
modelRatio: modelRatioValue,
|
||
completionRatio: completionRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(completionTokens / 1000000) *
|
||
inputRatioPrice *
|
||
completionRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
),
|
||
buildBillingText(
|
||
'音频输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: audioInputTokens,
|
||
modelRatio: modelRatioValue,
|
||
audioRatio: audioRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(audioInputTokens / 1000000) *
|
||
inputRatioPrice *
|
||
audioRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
),
|
||
buildBillingText(
|
||
'音频输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 音频倍率 {{audioRatio}} * 音频补全倍率 {{audioCompletionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: audioCompletionTokens,
|
||
modelRatio: modelRatioValue,
|
||
audioRatio: audioRatioValue,
|
||
audioCompletionRatio: audioCompletionRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(audioCompletionTokens / 1000000) *
|
||
inputRatioPrice *
|
||
audioRatioValue *
|
||
audioCompletionRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
),
|
||
buildBillingText(
|
||
'合计:文字部分 {{textTotal}} + 音频部分 {{audioTotal}} = {{total}}',
|
||
{
|
||
textTotal: renderDisplayAmountFromUsd(textPrice),
|
||
audioTotal: renderDisplayAmountFromUsd(audioPrice),
|
||
total: renderDisplayAmountFromUsd(totalPrice),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
export function renderQuotaWithPrompt(quota, digits) {
|
||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||
if (quotaDisplayType !== 'TOKENS') {
|
||
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
export function renderClaudeModelPrice(opts) {
|
||
const {
|
||
prompt_tokens: inputTokens = 0,
|
||
completion_tokens: completionTokens = 0,
|
||
model_ratio: modelRatio = 0,
|
||
model_price: modelPrice = -1,
|
||
completion_ratio: _completionRatio,
|
||
group_ratio: _groupRatio,
|
||
user_group_ratio,
|
||
cache_tokens: cacheTokens = 0,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
cache_creation_tokens: cacheCreationTokens = 0,
|
||
cache_creation_ratio: cacheCreationRatio = 1.0,
|
||
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
|
||
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
|
||
displayMode = 'price',
|
||
} = opts;
|
||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||
_groupRatio,
|
||
user_group_ratio,
|
||
);
|
||
let groupRatio = effectiveGroupRatio;
|
||
const completionRatio = _completionRatio ?? 0;
|
||
|
||
// 获取货币配置
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
|
||
if (!shouldUseRatioBillingProcess(modelPrice)) {
|
||
if (modelPrice !== -1) {
|
||
return renderBillingArticle([
|
||
buildBillingPriceText('模型价格:{{symbol}}{{price}} / 次', {
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText(
|
||
'模型价格 {{symbol}}{{price}} / 次 * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol,
|
||
usdAmount: modelPrice,
|
||
rate,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
total: formatBillingDisplayPrice(modelPrice * groupRatio, rate),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatio;
|
||
const cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
|
||
const cacheCreationRatioPrice = modelRatio * 2.0 * cacheCreationRatio;
|
||
const cacheCreationRatioPrice5m = modelRatio * 2.0 * cacheCreationRatio5m;
|
||
const cacheCreationRatioPrice1h = modelRatio * 2.0 * cacheCreationRatio1h;
|
||
const hasSplitCacheCreation =
|
||
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
const legacyCacheCreationTokens = hasSplitCacheCreation
|
||
? 0
|
||
: cacheCreationTokens;
|
||
const effectiveInputTokens =
|
||
inputTokens +
|
||
cacheTokens * cacheRatio +
|
||
legacyCacheCreationTokens * cacheCreationRatio +
|
||
cacheCreationTokens5m * cacheCreationRatio5m +
|
||
cacheCreationTokens1h * cacheCreationRatio1h;
|
||
const price =
|
||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
||
const inputUnitPrice = inputRatioPrice * rate;
|
||
const completionUnitPrice = completionRatioPrice * rate;
|
||
const cacheUnitPrice = cacheRatioPrice * rate;
|
||
const cacheCreationUnitPrice = cacheCreationRatioPrice * rate;
|
||
const cacheCreationUnitPrice5m = cacheCreationRatioPrice5m * rate;
|
||
const cacheCreationUnitPrice1h = cacheCreationRatioPrice1h * rate;
|
||
const cacheCreationUnitPriceTotal =
|
||
cacheCreationUnitPrice5m + cacheCreationUnitPrice1h;
|
||
const shouldShowCache = cacheTokens > 0;
|
||
const shouldShowLegacyCacheCreation =
|
||
!hasSplitCacheCreation && cacheCreationTokens > 0;
|
||
const shouldShowCacheCreation5m =
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0;
|
||
const shouldShowCacheCreation1h =
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0;
|
||
|
||
const breakdownSegments = [
|
||
i18next.t('提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}}', {
|
||
input: inputTokens,
|
||
symbol,
|
||
price: inputUnitPrice.toFixed(6),
|
||
}),
|
||
];
|
||
|
||
if (shouldShowCache) {
|
||
breakdownSegments.push(
|
||
i18next.t('缓存 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}', {
|
||
tokens: cacheTokens,
|
||
symbol,
|
||
price: cacheUnitPrice.toFixed(6),
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (shouldShowLegacyCacheCreation) {
|
||
breakdownSegments.push(
|
||
i18next.t(
|
||
'缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
tokens: cacheCreationTokens,
|
||
symbol,
|
||
price: cacheCreationUnitPrice.toFixed(6),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
if (shouldShowCacheCreation5m) {
|
||
breakdownSegments.push(
|
||
i18next.t(
|
||
'5m缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
tokens: cacheCreationTokens5m,
|
||
symbol,
|
||
price: cacheCreationUnitPrice5m.toFixed(6),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
if (shouldShowCacheCreation1h) {
|
||
breakdownSegments.push(
|
||
i18next.t(
|
||
'1h缓存创建 {{tokens}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
tokens: cacheCreationTokens1h,
|
||
symbol,
|
||
price: cacheCreationUnitPrice1h.toFixed(6),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
breakdownSegments.push(
|
||
i18next.t(
|
||
'补全 {{completion}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||
{
|
||
completion: completionTokens,
|
||
symbol,
|
||
price: completionUnitPrice.toFixed(6),
|
||
},
|
||
),
|
||
);
|
||
|
||
const breakdownText = breakdownSegments.join(' + ');
|
||
|
||
return renderBillingArticle([
|
||
buildBillingPriceText('输入价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: inputRatioPrice,
|
||
rate,
|
||
}),
|
||
buildBillingPriceText('输出价格:{{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
usdAmount: completionRatioPrice,
|
||
rate,
|
||
}),
|
||
cacheTokens > 0
|
||
? buildBillingPriceText(
|
||
'缓存读取价格:{{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: cacheRatioPrice,
|
||
rate,
|
||
},
|
||
)
|
||
: null,
|
||
!hasSplitCacheCreation && cacheCreationTokens > 0
|
||
? buildBillingPriceText(
|
||
'缓存创建价格:{{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: cacheCreationRatioPrice,
|
||
rate,
|
||
},
|
||
)
|
||
: null,
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0
|
||
? buildBillingPriceText(
|
||
'5m缓存创建价格:{{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: cacheCreationRatioPrice5m,
|
||
rate,
|
||
},
|
||
)
|
||
: null,
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0
|
||
? buildBillingPriceText(
|
||
'1h缓存创建价格:{{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
usdAmount: cacheCreationRatioPrice1h,
|
||
rate,
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingText(
|
||
'{{breakdown}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
breakdown: breakdownText,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
symbol,
|
||
total: formatBillingDisplayPrice(price, rate),
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
|
||
if (modelPrice !== -1) {
|
||
return i18next.t(
|
||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||
{
|
||
symbol: symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||
},
|
||
);
|
||
}
|
||
|
||
const modelRatioValue = formatRatioValue(modelRatio);
|
||
const completionRatioValue = formatRatioValue(completionRatio);
|
||
const cacheRatioValue = formatRatioValue(cacheRatio);
|
||
const cacheCreationRatioValue = formatRatioValue(cacheCreationRatio);
|
||
const cacheCreationRatio5mValue = formatRatioValue(cacheCreationRatio5m);
|
||
const cacheCreationRatio1hValue = formatRatioValue(cacheCreationRatio1h);
|
||
|
||
const inputRatioPrice = modelRatio * 2.0;
|
||
const completionRatioPrice = modelRatio * 2.0 * completionRatioValue;
|
||
|
||
const hasSplitCacheCreation =
|
||
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
const shouldShowCache = cacheTokens > 0;
|
||
const shouldShowLegacyCacheCreation =
|
||
!hasSplitCacheCreation && cacheCreationTokens > 0;
|
||
const shouldShowCacheCreation5m =
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0;
|
||
const shouldShowCacheCreation1h =
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0;
|
||
|
||
const legacyCacheCreationTokens = hasSplitCacheCreation
|
||
? 0
|
||
: cacheCreationTokens;
|
||
const effectiveInputTokens =
|
||
inputTokens +
|
||
cacheTokens * cacheRatioValue +
|
||
legacyCacheCreationTokens * cacheCreationRatioValue +
|
||
cacheCreationTokens5m * cacheCreationRatio5mValue +
|
||
cacheCreationTokens1h * cacheCreationRatio1hValue;
|
||
|
||
const totalAmount =
|
||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||
(completionTokens / 1000000) * completionRatioPrice * groupRatio;
|
||
|
||
return renderBillingArticle([
|
||
buildBillingText(
|
||
'模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},{{ratioType}} {{ratio}}',
|
||
{
|
||
modelRatio: modelRatioValue,
|
||
completionRatio: completionRatioValue,
|
||
cacheRatio: cacheRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
},
|
||
),
|
||
hasSplitCacheCreation
|
||
? buildBillingText(
|
||
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
|
||
{
|
||
cacheCreationRatio5m: cacheCreationRatio5mValue,
|
||
cacheCreationRatio1h: cacheCreationRatio1hValue,
|
||
},
|
||
)
|
||
: buildBillingText('缓存创建倍率 {{cacheCreationRatio}}', {
|
||
cacheCreationRatio: cacheCreationRatioValue,
|
||
}),
|
||
buildBillingText(
|
||
'普通输入:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: inputTokens,
|
||
modelRatio: modelRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(inputTokens / 1000000) * inputRatioPrice * groupRatio,
|
||
),
|
||
},
|
||
),
|
||
shouldShowCache
|
||
? buildBillingText(
|
||
'缓存读取:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存倍率 {{cacheRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheTokens,
|
||
modelRatio: modelRatioValue,
|
||
cacheRatio: cacheRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(cacheTokens / 1000000) *
|
||
inputRatioPrice *
|
||
cacheRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
)
|
||
: null,
|
||
shouldShowLegacyCacheCreation
|
||
? buildBillingText(
|
||
'缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 缓存创建倍率 {{cacheCreationRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheCreationTokens,
|
||
modelRatio: modelRatioValue,
|
||
cacheCreationRatio: cacheCreationRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(cacheCreationTokens / 1000000) *
|
||
inputRatioPrice *
|
||
cacheCreationRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
)
|
||
: null,
|
||
shouldShowCacheCreation5m
|
||
? buildBillingText(
|
||
'5m缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 5m缓存创建倍率 {{cacheCreationRatio5m}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheCreationTokens5m,
|
||
modelRatio: modelRatioValue,
|
||
cacheCreationRatio5m: cacheCreationRatio5mValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(cacheCreationTokens5m / 1000000) *
|
||
inputRatioPrice *
|
||
cacheCreationRatio5mValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
)
|
||
: null,
|
||
shouldShowCacheCreation1h
|
||
? buildBillingText(
|
||
'1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: cacheCreationTokens1h,
|
||
modelRatio: modelRatioValue,
|
||
cacheCreationRatio1h: cacheCreationRatio1hValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(cacheCreationTokens1h / 1000000) *
|
||
inputRatioPrice *
|
||
cacheCreationRatio1hValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
)
|
||
: null,
|
||
buildBillingText(
|
||
'补全 {{completion}} tokens * 输出倍率 {{completionRatio}}',
|
||
{
|
||
completion: completionTokens,
|
||
completionRatio: completionRatioValue,
|
||
},
|
||
),
|
||
buildBillingText(
|
||
'输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}',
|
||
{
|
||
tokens: completionTokens,
|
||
modelRatio: modelRatioValue,
|
||
completionRatio: completionRatioValue,
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
amount: renderDisplayAmountFromUsd(
|
||
(completionTokens / 1000000) *
|
||
inputRatioPrice *
|
||
completionRatioValue *
|
||
groupRatio,
|
||
),
|
||
},
|
||
),
|
||
buildBillingText('合计:{{total}}', {
|
||
total: renderDisplayAmountFromUsd(totalAmount),
|
||
}),
|
||
]);
|
||
}
|
||
|
||
export function renderClaudeLogContent(opts) {
|
||
const {
|
||
model_ratio: modelRatio,
|
||
completion_ratio: completionRatio,
|
||
model_price: modelPrice = -1,
|
||
group_ratio: _groupRatio,
|
||
user_group_ratio,
|
||
cache_ratio: cacheRatio = 1.0,
|
||
cache_creation_ratio: cacheCreationRatio = 1.0,
|
||
cache_creation_tokens_5m: cacheCreationTokens5m = 0,
|
||
cache_creation_ratio_5m: cacheCreationRatio5m = 1.0,
|
||
cache_creation_tokens_1h: cacheCreationTokens1h = 0,
|
||
cache_creation_ratio_1h: cacheCreationRatio1h = 1.0,
|
||
displayMode = 'price',
|
||
} = opts;
|
||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||
_groupRatio,
|
||
user_group_ratio,
|
||
);
|
||
let groupRatio = effectiveGroupRatio;
|
||
|
||
// 获取货币配置
|
||
const { symbol, rate } = getCurrencyConfig();
|
||
|
||
if (isPriceDisplayMode(displayMode, modelPrice)) {
|
||
if (modelPrice !== -1) {
|
||
return joinBillingSummary([
|
||
i18next.t('模型价格 {{symbol}}{{price}} / 次', {
|
||
symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
}),
|
||
getGroupRatioText(groupRatio, user_group_ratio),
|
||
]);
|
||
}
|
||
|
||
const parts = [
|
||
i18next.t('输入价格 {{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
price: (modelRatio * 2.0 * rate).toFixed(6),
|
||
}),
|
||
i18next.t('输出价格 {{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
price: (modelRatio * 2.0 * completionRatio * rate).toFixed(6),
|
||
}),
|
||
i18next.t('缓存读取价格 {{symbol}}{{price}} / 1M tokens', {
|
||
symbol,
|
||
price: (modelRatio * 2.0 * cacheRatio * rate).toFixed(6),
|
||
}),
|
||
];
|
||
const hasSplitCacheCreation =
|
||
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
appendPricePart(
|
||
parts,
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0,
|
||
'5m缓存创建价格 {{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
price: (modelRatio * 2.0 * cacheCreationRatio5m * rate).toFixed(6),
|
||
},
|
||
);
|
||
appendPricePart(
|
||
parts,
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0,
|
||
'1h缓存创建价格 {{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
price: (modelRatio * 2.0 * cacheCreationRatio1h * rate).toFixed(6),
|
||
},
|
||
);
|
||
appendPricePart(
|
||
parts,
|
||
!hasSplitCacheCreation,
|
||
'缓存创建价格 {{symbol}}{{price}} / 1M tokens',
|
||
{
|
||
symbol,
|
||
price: (modelRatio * 2.0 * cacheCreationRatio * rate).toFixed(6),
|
||
},
|
||
);
|
||
parts.push(getGroupRatioText(groupRatio, user_group_ratio));
|
||
return joinBillingSummary(parts);
|
||
}
|
||
|
||
if (modelPrice !== -1) {
|
||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||
symbol: symbol,
|
||
price: (modelPrice * rate).toFixed(6),
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
});
|
||
} else {
|
||
const hasSplitCacheCreation =
|
||
cacheCreationTokens5m > 0 || cacheCreationTokens1h > 0;
|
||
const shouldShowCacheCreation5m =
|
||
hasSplitCacheCreation && cacheCreationTokens5m > 0;
|
||
const shouldShowCacheCreation1h =
|
||
hasSplitCacheCreation && cacheCreationTokens1h > 0;
|
||
|
||
let cacheCreationPart = null;
|
||
if (hasSplitCacheCreation) {
|
||
if (shouldShowCacheCreation5m && shouldShowCacheCreation1h) {
|
||
cacheCreationPart = i18next.t(
|
||
'缓存创建倍率 5m {{cacheCreationRatio5m}} / 1h {{cacheCreationRatio1h}}',
|
||
{
|
||
cacheCreationRatio5m,
|
||
cacheCreationRatio1h,
|
||
},
|
||
);
|
||
} else if (shouldShowCacheCreation5m) {
|
||
cacheCreationPart = i18next.t(
|
||
'缓存创建倍率 5m {{cacheCreationRatio5m}}',
|
||
{
|
||
cacheCreationRatio5m,
|
||
},
|
||
);
|
||
} else if (shouldShowCacheCreation1h) {
|
||
cacheCreationPart = i18next.t(
|
||
'缓存创建倍率 1h {{cacheCreationRatio1h}}',
|
||
{
|
||
cacheCreationRatio1h,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!cacheCreationPart) {
|
||
cacheCreationPart = i18next.t('缓存创建倍率 {{cacheCreationRatio}}', {
|
||
cacheCreationRatio,
|
||
});
|
||
}
|
||
|
||
const parts = [
|
||
i18next.t('模型倍率 {{modelRatio}}', { modelRatio }),
|
||
i18next.t('输出倍率 {{completionRatio}}', { completionRatio }),
|
||
i18next.t('缓存倍率 {{cacheRatio}}', { cacheRatio }),
|
||
cacheCreationPart,
|
||
i18next.t('{{ratioType}} {{ratio}}', {
|
||
ratioType: ratioLabel,
|
||
ratio: groupRatio,
|
||
}),
|
||
];
|
||
|
||
return parts.join(',');
|
||
}
|
||
}
|
||
|
||
// 已统一至 renderModelPriceSimple,若仍有遗留引用,请改为传入 provider='claude'
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
});
|
||
};
|
||
}
|