🚀 feat: launch v1.0 — next-generation frontend built from the ground up (#4265)

* 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 in d2150469 moved 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 (b8650b9 merge) 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(sync 59337e9): 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 (commit 1be6cdb) 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 classic 59337e9

Port and optimize the remaining web/classic features from commit 59337e9 to 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 classic 73e5557 tiered-billing fixes and dedupe Title-Case ratio i18n keys

Sync the web/classic frontend fixes from upstream merge 73e5557 to
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 of 9f8a4ec05)
- 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 of bee339d27)
- 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 fix 4e93148d9
- 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>
This commit is contained in:
同語
2026-04-28 14:19:19 +08:00
committed by GitHub
parent 9f8a4ec050
commit a42b397607
1290 changed files with 158786 additions and 53 deletions
+7
View File
@@ -0,0 +1,7 @@
import { api } from '@/lib/api'
import type { AboutResponse } from './types'
export async function getAboutContent() {
const res = await api.get<AboutResponse>('/api/about')
return res.data
}
+168
View File
@@ -0,0 +1,168 @@
import { useQuery } from '@tanstack/react-query'
import { Construction } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/components/ui/markdown'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout'
import { getAboutContent } from './api'
function isValidUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function isLikelyHtml(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function EmptyAboutState() {
const { t } = useTranslation()
const currentYear = new Date().getFullYear()
return (
<div className='flex min-h-[60vh] items-center justify-center p-8'>
<div className='max-w-2xl space-y-6 text-center'>
<div className='flex justify-center'>
<Construction className='text-muted-foreground h-24 w-24' />
</div>
<div className='space-y-2'>
<h2 className='text-2xl font-bold'>{t('No About Content Set')}</h2>
<p className='text-muted-foreground'>
{t(
'The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.'
)}
</p>
</div>
<div className='space-y-4 text-sm'>
<p>
{t('New API Project Repository:')}{' '}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('https://github.com/QuantumNous/new-api')}
</a>
</p>
<p className='text-muted-foreground'>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('NewAPI')}
</a>{' '}
© {currentYear}{' '}
<a
href='https://github.com/QuantumNous'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('QuantumNous')}
</a>{' '}
{t('| Based on')}{' '}
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('One API')}
</a>{' '}
© 2023{' '}
<a
href='https://github.com/songquanpeng'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('JustSong')}
</a>
</p>
<p className='text-muted-foreground'>
{t('This project must be used in compliance with the')}{' '}
<a
href='https://github.com/QuantumNous/new-api/blob/main/LICENSE'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('AGPL v3.0 License')}
</a>
.
</p>
</div>
</div>
</div>
)
}
export function About() {
const { t } = useTranslation()
const { data, isLoading } = useQuery({
queryKey: ['about-content'],
queryFn: getAboutContent,
})
const rawContent = data?.data?.trim() ?? ''
const hasContent = rawContent.length > 0
const isUrl = hasContent && isValidUrl(rawContent)
const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
if (isLoading) {
return (
<PublicLayout>
<div className='mx-auto flex max-w-4xl flex-col gap-4 py-12'>
<Skeleton className='h-8 w-[45%]' />
<Skeleton className='h-4 w-full' />
<Skeleton className='h-4 w-[90%]' />
<Skeleton className='h-4 w-[80%]' />
</div>
</PublicLayout>
)
}
if (!hasContent) {
return (
<PublicLayout>
<EmptyAboutState />
</PublicLayout>
)
}
if (isUrl) {
return (
<PublicLayout showMainContainer={false}>
<iframe
src={rawContent}
className='h-[calc(100vh-3.5rem)] w-full border-0'
title={t('About')}
/>
</PublicLayout>
)
}
return (
<PublicLayout>
<div className='mx-auto max-w-6xl px-4 py-8'>
{isHtml ? (
<div
className='prose prose-neutral dark:prose-invert max-w-none'
dangerouslySetInnerHTML={{ __html: rawContent }}
/>
) : (
<Markdown className='prose-neutral dark:prose-invert max-w-none'>
{rawContent}
</Markdown>
)}
</div>
</PublicLayout>
)
}
+5
View File
@@ -0,0 +1,5 @@
export type AboutResponse = {
success: boolean
message: string
data?: string
}
+116
View File
@@ -0,0 +1,116 @@
import { api } from '@/lib/api'
import type {
LoginPayload,
LoginResponse,
Login2FAResponse,
TwoFAPayload,
RegisterPayload,
ApiResponse,
} from './types'
// ============================================================================
// Authentication APIs
// ============================================================================
// ----------------------------------------------------------------------------
// Login & Logout
// ----------------------------------------------------------------------------
// User login with username and password
export async function login(payload: LoginPayload) {
const turnstile = payload.turnstile ?? ''
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password: payload.password,
}
)
return res.data
}
// Two-factor authentication login
export async function login2fa(payload: TwoFAPayload) {
const res = await api.post<Login2FAResponse>('/api/user/login/2fa', payload)
return res.data
}
// User logout
export async function logout(): Promise<ApiResponse> {
const res = await api.get('/api/user/logout')
return res.data
}
// ----------------------------------------------------------------------------
// Password Management
// ----------------------------------------------------------------------------
// Send password reset email
export async function sendPasswordResetEmail(
email: string,
turnstile?: string
): Promise<ApiResponse> {
const res = await api.get('/api/reset_password', {
params: { email, turnstile },
})
return res.data
}
// ----------------------------------------------------------------------------
// OAuth
// ----------------------------------------------------------------------------
// Start GitHub OAuth flow
export async function githubOAuthStart(clientId: string, state: string) {
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
window.open(url)
}
// Get OAuth state for CSRF protection
export async function getOAuthState(): Promise<string> {
const aff =
typeof window !== 'undefined' ? (localStorage.getItem('aff') ?? '') : ''
const res = await api.get('/api/oauth/state', { params: { aff } })
if (res.data?.success) return res.data.data
return ''
}
// WeChat login by authorization code
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
const res = await api.get('/api/oauth/wechat', { params: { code } })
return res.data
}
// ----------------------------------------------------------------------------
// Registration
// ----------------------------------------------------------------------------
// User registration
export async function register(payload: RegisterPayload): Promise<ApiResponse> {
const res = await api.post(`/api/user/register`, payload, {
params: { turnstile: payload.turnstile ?? '' },
})
return res.data
}
// Send email verification code
export async function sendEmailVerification(
email: string,
turnstile?: string
): Promise<ApiResponse> {
const res = await api.get('/api/verification', {
params: { email, turnstile },
})
return res.data
}
// Bind email to OAuth account
export async function bindEmail(
email: string,
code: string
): Promise<ApiResponse> {
const res = await api.get('/api/oauth/email/bind', {
params: { email, code },
})
return res.data
}
+44
View File
@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Skeleton } from '@/components/ui/skeleton'
type AuthLayoutProps = {
children: React.ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
const { t } = useTranslation()
const { systemName, logo, loading } = useSystemConfig()
return (
<div className='relative grid h-svh max-w-none'>
<Link
to='/'
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
>
<div className='relative h-8 w-8'>
{loading ? (
<Skeleton className='absolute inset-0 rounded-full' />
) : (
<img
src={logo}
alt={t('Logo')}
className='h-8 w-8 rounded-full object-cover'
/>
)}
</div>
{loading ? (
<Skeleton className='h-6 w-24' />
) : (
<h1 className='text-xl font-medium'>{systemName}</h1>
)}
</Link>
<div className='container flex items-center pt-16 sm:pt-0'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import type { SystemStatus } from '../types'
interface LegalConsentProps {
status: SystemStatus | null
checked: boolean
onCheckedChange: (nextValue: boolean) => void
className?: string
}
export function LegalConsent({
status,
checked,
onCheckedChange,
className,
}: LegalConsentProps) {
const { t } = useTranslation()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
if (!hasUserAgreement && !hasPrivacyPolicy) {
return null
}
const handleChange = (value: boolean | 'indeterminate') => {
onCheckedChange(value === true)
}
return (
<div
className={cn(
'border-border/60 bg-muted/40 flex items-start gap-3 rounded-md border p-3',
className
)}
>
<Checkbox
id='legal-consent'
checked={checked}
onCheckedChange={handleChange}
className='mt-0.5'
/>
<Label
htmlFor='legal-consent'
className='text-muted-foreground items-start gap-1 text-left text-xs leading-5 font-normal'
>
<span>
{t('I have read and agree to the')}{' '}
{hasUserAgreement && (
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('User Agreement')}
</a>
)}
{hasUserAgreement && hasPrivacyPolicy && ' and the '}
{hasPrivacyPolicy && (
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-primary hover:underline'
>
{t('Privacy Policy')}
</a>
)}
.
</span>
</Label>
</div>
)
}
@@ -0,0 +1,106 @@
import { useMemo } from 'react'
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
import { AuthLayout } from '../auth-layout'
type OAuthCallbackScreenProps = {
provider: string
mode: 'login' | 'bind'
}
type ProviderMeta = {
label: string
Icon: LucideIcon | ((props: { className?: string }) => React.JSX.Element)
}
const providerDictionary: Record<string, ProviderMeta> = {
github: {
label: 'GitHub',
Icon: (props: { className?: string }) => (
<SiGithub className={props.className} focusable='false' />
),
},
oidc: { label: 'OIDC', Icon: Shield },
linuxdo: {
label: 'LinuxDO',
Icon: (props: { className?: string }) => (
<SiLinux className={props.className} focusable='false' />
),
},
telegram: { label: 'Telegram', Icon: Send },
wechat: {
label: 'WeChat',
Icon: (props: { className?: string }) => (
<SiWechat className={props.className} focusable='false' />
),
},
}
export function OAuthCallbackScreen({
provider,
mode,
}: OAuthCallbackScreenProps) {
const { t } = useTranslation()
const { label, Icon } = useMemo(() => {
const normalized = provider?.toLowerCase() ?? ''
return (
providerDictionary[normalized] || {
label: 'account',
Icon: UserRound,
}
)
}, [provider])
const providerLabel = t(label)
const isBindMode = mode === 'bind'
const headline = isBindMode
? t('Binding your {{provider}} account', { provider: providerLabel })
: t('Signing you in with {{provider}}', { provider: providerLabel })
const description = isBindMode
? t('Hang tight while we securely link this account to your profile.')
: t('Hang tight while we finish connecting your account.')
const secondaryNote = isBindMode
? t(
'You can close this tab once the binding completes or a success message appears in the original window.'
)
: t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
)
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='flex flex-col items-center space-y-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
<Icon className='h-8 w-8' />
</div>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight'>
{headline}
</h2>
<p className='text-muted-foreground text-sm sm:text-base'>
{description}
</p>
</div>
</div>
<div className='space-y-4 text-center'>
<div className='flex items-center justify-center gap-2 text-sm font-medium'>
<Loader2 className='h-4 w-4 animate-spin' />
<span>{t('Processing OAuth response...')}</span>
</div>
<p className='text-muted-foreground text-sm'>{secondaryNote}</p>
<p className='text-muted-foreground text-xs'>
{t(
'This may take a few moments while we validate the request and update your session.'
)}
</p>
</div>
</div>
</AuthLayout>
)
}
@@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import {
IconDiscord,
IconGithub,
IconLinuxDo,
IconWeChat,
} from '@/assets/brand-icons'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useOAuthLogin } from '../hooks/use-oauth-login'
import type { SystemStatus } from '../types'
type OAuthProvidersProps = {
status: SystemStatus | null
disabled?: boolean
className?: string
onWeChatLogin?: () => void
isWeChatLoading?: boolean
}
type ProviderButton = {
key: string
label: string
onClick: () => void
icon?: ReactNode
disabled?: boolean
}
export function OAuthProviders({
status,
disabled = false,
className,
onWeChatLogin,
isWeChatLoading = false,
}: OAuthProvidersProps) {
const { t } = useTranslation()
const {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
} = useOAuthLogin(status)
const providerButtons: ProviderButton[] = []
if (status?.wechat_login && onWeChatLogin) {
providerButtons.push({
key: 'wechat',
label: t('Continue with WeChat'),
onClick: onWeChatLogin,
icon: <IconWeChat className='h-4 w-4' />,
disabled: isWeChatLoading,
})
}
if (status?.github_oauth) {
providerButtons.push({
key: 'github',
label: githubButtonText || t('Continue with GitHub'),
onClick: handleGitHubLogin,
icon: <IconGithub className='h-4 w-4' />,
disabled: githubButtonDisabled,
})
}
if (status?.discord_oauth) {
providerButtons.push({
key: 'discord',
label: t('Continue with Discord'),
onClick: handleDiscordLogin,
icon: <IconDiscord className='h-4 w-4' />,
})
}
if (status?.oidc_enabled) {
providerButtons.push({
key: 'oidc',
label: t('Continue with OIDC'),
onClick: handleOIDCLogin,
})
}
if (status?.linuxdo_oauth) {
providerButtons.push({
key: 'linuxdo',
label: t('Continue with LinuxDO'),
onClick: handleLinuxDOLogin,
icon: <IconLinuxDo className='h-4 w-4' />,
})
}
if (status?.telegram_oauth) {
providerButtons.push({
key: 'telegram',
label: t('Continue with Telegram'),
onClick: handleTelegramLogin,
})
}
// Custom OAuth providers
const customProviders = status?.custom_oauth_providers
if (customProviders && customProviders.length > 0) {
for (const provider of customProviders) {
providerButtons.push({
key: `custom-${provider.slug}`,
label: t('Continue with {{name}}', { name: provider.name }),
onClick: () => handleCustomOAuthLogin(provider),
})
}
}
if (providerButtons.length === 0) return null
return (
<div className={cn('space-y-3', className)}>
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background text-muted-foreground px-2'>
{t('Or continue with')}
</span>
</div>
</div>
<div className='flex flex-col gap-2'>
{providerButtons.map(
({ key, label, onClick, icon, disabled: extraDisabled }) => (
<Button
key={key}
variant='outline'
type='button'
disabled={disabled || isLoading || extraDisabled}
onClick={onClick}
className='h-11 w-full justify-center gap-2 rounded-lg'
>
{icon}
{label}
</Button>
)
)}
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { SystemStatus } from '../types'
interface TermsFooterProps {
variant?: 'sign-in' | 'sign-up'
className?: string
status?: SystemStatus | null
}
export function TermsFooter({
variant = 'sign-in',
className,
status,
}: TermsFooterProps) {
const { t } = useTranslation()
const text =
variant === 'sign-in'
? 'By clicking sign in, you agree to our'
: 'By creating an account, you agree to our'
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
if (!hasUserAgreement && !hasPrivacyPolicy) {
return null
}
const agreementLink = {
label: 'User Agreement',
href: '/user-agreement',
}
const privacyLink = {
label: 'Privacy Policy',
href: '/privacy-policy',
}
const activeLinks =
hasUserAgreement || hasPrivacyPolicy
? ([
hasUserAgreement ? agreementLink : null,
hasPrivacyPolicy ? privacyLink : null,
].filter(Boolean) as Array<{ label: string; href: string }>)
: [agreementLink, privacyLink]
const [firstLink, secondLink] = activeLinks
return (
<p className={cn('text-muted-foreground text-center text-xs', className)}>
{text}{' '}
{firstLink && (
<a
href={firstLink.href}
className='hover:text-primary underline underline-offset-4'
>
{firstLink.label}
</a>
)}
{secondLink && (
<>
{' '}
{t('and')}{' '}
<a
href={secondLink.href}
className='hover:text-primary underline underline-offset-4'
>
{secondLink.label}
</a>
</>
)}
.
</p>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { z } from 'zod'
// ============================================================================
// Form Schemas
// ============================================================================
export const loginFormSchema = z.object({
username: z.string().min(1, 'Please enter your username or email'),
password: z
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be at least 8 characters long'),
})
export const registerFormSchema = z
.object({
username: z.string().min(1, 'Please enter your username'),
email: z.string().optional(),
password: z
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be at least 8 characters long')
.max(20, 'Password must be at most 20 characters long'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match.",
path: ['confirmPassword'],
})
export const forgotPasswordFormSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address',
}),
})
export const otpFormSchema = z.object({
otp: z.string().min(1, 'Please enter a code.'),
})
// ============================================================================
// Validation Constants
// ============================================================================
export const PASSWORD_MIN_LENGTH = 8
export const PASSWORD_MAX_LENGTH = 20
export const OTP_LENGTH = 6
export const BACKUP_CODE_LENGTH = 9 // XXXX-XXXX format
export const BACKUP_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/i
export const OTP_REGEX = /^\d{6}$/
// ============================================================================
// Countdown Constants
// ============================================================================
export const EMAIL_VERIFICATION_COUNTDOWN = 30 // seconds
export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
// ============================================================================
// OAuth Constants
// ============================================================================
export const OAUTH_BIND_STORAGE_KEY = 'oauth:binding:result'
@@ -0,0 +1,108 @@
import { useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ArrowRight, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useCountdown } from '@/hooks/use-countdown'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Turnstile } from '@/components/turnstile'
import { sendPasswordResetEmail } from '@/features/auth/api'
import {
forgotPasswordFormSchema,
PASSWORD_RESET_COUNTDOWN,
} from '@/features/auth/constants'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
export function ForgotPasswordForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: PASSWORD_RESET_COUNTDOWN })
const form = useForm<z.infer<typeof forgotPasswordFormSchema>>({
resolver: zodResolver(forgotPasswordFormSchema),
defaultValues: { email: '' },
})
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
if (!validateTurnstile()) return
setIsLoading(true)
try {
const res = await sendPasswordResetEmail(data.email, turnstileToken)
if (res?.success) {
form.reset()
startCountdown()
toast.success(t('Reset email sent, please check your inbox'))
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading || isActive}>
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
</form>
</Form>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from '../auth-layout'
import { ForgotPasswordForm } from './components/forgot-password-form'
export function ForgotPassword() {
const { t } = useTranslation()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-3'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Forgot password')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t(
'Enter your registered email and we will send you a link to reset your password.'
)}
</p>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
to='/sign-up'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign up')}
</Link>
.
</p>
</div>
<ForgotPasswordForm className='space-y-0' />
</div>
</AuthLayout>
)
}
@@ -0,0 +1,86 @@
import { useNavigate } from '@tanstack/react-router'
import i18n from 'i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getSelf } from '@/lib/api'
import type { User } from '@/features/users/types'
import { saveUserId } from '../lib/storage'
/**
* Hook for handling authentication redirects and user data management
*/
export function useAuthRedirect() {
const navigate = useNavigate()
const { auth } = useAuthStore()
/**
* Handle successful login
* @param userData - Optional user data from login response
* @param redirectTo - Redirect path after login
*/
const handleLoginSuccess = async (
userData?: { id?: number } | null,
redirectTo?: string
) => {
// Save user ID if available
if (userData?.id) {
saveUserId(userData.id)
}
// Fetch and set user data
try {
const self = await getSelf()
if (self?.success && self.data) {
const user = self.data as User
auth.setUser(user)
// Update user ID if not already set
if (user.id) {
saveUserId(user.id)
}
// Restore saved language preference
const savedLang = (user as Record<string, unknown>).language as
| string
| undefined
if (savedLang && savedLang !== i18n.language) {
i18n.changeLanguage(savedLang)
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user data:', error)
}
// Navigate to target page
const targetPath = redirectTo || '/dashboard'
navigate({ to: targetPath, replace: true })
}
/**
* Redirect to 2FA page
*/
const redirectTo2FA = () => {
navigate({ to: '/otp', replace: true })
}
/**
* Redirect to login page
*/
const redirectToLogin = () => {
navigate({ to: '/sign-in', replace: true })
}
/**
* Redirect to register page
*/
const redirectToRegister = () => {
navigate({ to: '/sign-up', replace: true })
}
return {
handleLoginSuccess,
redirectTo2FA,
redirectToLogin,
redirectToRegister,
}
}
@@ -0,0 +1,61 @@
import { useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { sendEmailVerification } from '../api'
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
interface UseEmailVerificationOptions {
turnstileToken?: string
validateTurnstile?: () => boolean
}
/**
* Hook for managing email verification code sending
*/
export function useEmailVerification(options?: UseEmailVerificationOptions) {
const [isSending, setIsSending] = useState(false)
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: EMAIL_VERIFICATION_COUNTDOWN })
/**
* Send verification code to email
*/
const sendCode = async (email: string) => {
if (!email) {
toast.error(i18next.t('Please enter your email first'))
return false
}
// Validate turnstile if validation function is provided
if (options?.validateTurnstile && !options.validateTurnstile()) {
return false
}
setIsSending(true)
try {
const res = await sendEmailVerification(email, options?.turnstileToken)
if (res?.success) {
startCountdown()
toast.success(i18next.t('Verification email sent'))
return true
}
return false
} catch (_error) {
// Errors are handled by global interceptor
return false
} finally {
setIsSending(false)
}
}
return {
isSending,
secondsLeft,
isActive,
sendCode,
}
}
+217
View File
@@ -0,0 +1,217 @@
import { useState, useRef, useEffect } from 'react'
import type { AxiosRequestConfig } from 'axios'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { getOAuthState } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '../lib/oauth'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
type LogoutRequestConfig = AxiosRequestConfig & {
skipErrorHandler?: boolean
}
/**
* Hook for managing OAuth login
*/
export function useOAuthLogin(status: SystemStatus | null) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const { auth } = useAuthStore()
useEffect(() => {
setGithubButtonText(t('Continue with GitHub'))
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
}
}, [t])
const resetSession = async () => {
try {
auth.reset()
} catch (_error) {
// ignore store reset errors
}
try {
await api.get('/api/user/logout', {
skipErrorHandler: true,
} as LogoutRequestConfig)
} catch (_error) {
// ignore logout errors
}
}
const handleGitHubLogin = async () => {
if (!status?.github_client_id) return
if (githubButtonDisabled) return
setIsLoading(true)
setGithubButtonDisabled(true)
setGithubButtonText(t('Redirecting to GitHub...'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
githubTimeoutRef.current = setTimeout(() => {
setIsLoading(false)
setGithubButtonText(
t('Request timed out, please refresh and restart GitHub login')
)
setGithubButtonDisabled(true)
}, 20000)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
return
}
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start GitHub login'))
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonDisabled(false)
}
}
const handleDiscordLogin = async () => {
if (!status?.discord_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start Discord login'))
} finally {
setIsLoading(false)
}
}
const handleOIDCLogin = async () => {
if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildOIDCOAuthUrl(
status.oidc_authorization_endpoint,
status.oidc_client_id,
state
)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start OIDC login'))
} finally {
setIsLoading(false)
}
}
const handleLinuxDOLogin = async () => {
if (!status?.linuxdo_client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
window.open(url, '_self')
} catch (_error) {
toast.error(t('Failed to start LinuxDO login'))
} finally {
setIsLoading(false)
}
}
const handleTelegramLogin = () => {
toast.info(t('Telegram login requires widget integration; coming soon'))
}
const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => {
if (!provider.authorization_endpoint || !provider.client_id) return
setIsLoading(true)
try {
await resetSession()
const state = await getOAuthState()
if (!state) {
toast.error(t('Failed to initialize OAuth'))
return
}
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
const url = new URL(provider.authorization_endpoint)
url.searchParams.set('client_id', provider.client_id)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('state', state)
if (provider.scopes) {
url.searchParams.set('scope', provider.scopes)
}
window.open(url.toString(), '_self')
} catch (_error) {
toast.error(
t('Failed to start {{provider}} login', { provider: provider.name })
)
} finally {
setIsLoading(false)
}
}
return {
isLoading,
githubButtonText,
githubButtonDisabled,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleCustomOAuthLogin,
}
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import { useStatus } from '@/hooks/use-status'
/**
* Hook for managing Turnstile verification
*/
export function useTurnstile() {
const { status } = useStatus()
const [turnstileToken, setTurnstileToken] = useState('')
const isTurnstileEnabled = !!(
status?.turnstile_check && status?.turnstile_site_key
)
const turnstileSiteKey = status?.turnstile_site_key || ''
/**
* Validate if turnstile is ready when required
*/
const validateTurnstile = (): boolean => {
if (isTurnstileEnabled && !turnstileToken) {
toast.info(
i18next.t('Please wait a moment, human check is initializing...')
)
return false
}
return true
}
return {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
}
}
+105
View File
@@ -0,0 +1,105 @@
// ============================================================================
// API Functions
// ============================================================================
export {
login,
login2fa,
logout,
register,
sendPasswordResetEmail,
sendEmailVerification,
bindEmail,
getOAuthState,
githubOAuthStart,
wechatLoginByCode,
} from './api'
// ============================================================================
// Types
// ============================================================================
export type {
LoginPayload,
LoginResponse,
Login2FAResponse,
TwoFAPayload,
RegisterPayload,
PasswordResetPayload,
EmailVerificationPayload,
BindEmailPayload,
ApiResponse,
SystemStatus,
OAuthProvider,
AuthFormProps,
} from './types'
// ============================================================================
// Constants & Schemas
// ============================================================================
export {
loginFormSchema,
registerFormSchema,
forgotPasswordFormSchema,
otpFormSchema,
PASSWORD_MIN_LENGTH,
PASSWORD_MAX_LENGTH,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
BACKUP_CODE_REGEX,
OTP_REGEX,
EMAIL_VERIFICATION_COUNTDOWN,
PASSWORD_RESET_COUNTDOWN,
} from './constants'
// ============================================================================
// Utilities
// ============================================================================
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
getAvailableOAuthProviders,
hasOAuthProviders,
} from './lib/oauth'
export {
saveUserId,
getUserId,
removeUserId,
getAffiliateCode,
saveAffiliateCode,
} from './lib/storage'
export {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
isValidEmail,
} from './lib/validation'
// ============================================================================
// Hooks
// ============================================================================
export { useTurnstile } from './hooks/use-turnstile'
export { useOAuthLogin } from './hooks/use-oauth-login'
export { useAuthRedirect } from './hooks/use-auth-redirect'
export { useEmailVerification } from './hooks/use-email-verification'
// ============================================================================
// Components
// ============================================================================
export { AuthLayout } from './auth-layout'
export { OAuthProviders } from './components/oauth-providers'
export { TermsFooter } from './components/terms-footer'
export { LegalConsent } from './components/legal-consent'
export { SignIn } from './sign-in'
export { SignUp } from './sign-up'
export { ForgotPassword } from './forgot-password'
export { Otp } from './otp'
+85
View File
@@ -0,0 +1,85 @@
import type { SystemStatus, OAuthProvider } from '../types'
export {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '@/lib/oauth'
// ============================================================================
// OAuth Providers Utilities
// ============================================================================
/**
* Get available OAuth providers from system status
*/
export function getAvailableOAuthProviders(
status: SystemStatus | null
): OAuthProvider[] {
if (!status) return []
const providers: OAuthProvider[] = []
if (status.github_oauth) {
providers.push({
name: 'GitHub',
type: 'github',
enabled: true,
clientId: status.github_client_id,
})
}
if (status.discord_oauth) {
providers.push({
name: 'Discord',
type: 'discord',
enabled: true,
clientId: status.discord_client_id,
})
}
if (status.oidc_enabled) {
providers.push({
name: 'OIDC',
type: 'oidc',
enabled: true,
clientId: status.oidc_client_id,
authEndpoint: status.oidc_authorization_endpoint,
})
}
if (status.linuxdo_oauth) {
providers.push({
name: 'LinuxDO',
type: 'linuxdo',
enabled: true,
clientId: status.linuxdo_client_id,
})
}
if (status.telegram_oauth) {
providers.push({
name: 'Telegram',
type: 'telegram',
enabled: true,
})
}
return providers
}
/**
* Check if any OAuth provider is available
*/
export function hasOAuthProviders(status: SystemStatus | null): boolean {
if (!status) return false
return !!(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.linuxdo_oauth ||
status.telegram_oauth ||
status.wechat_login
)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Utilities for managing authentication-related browser storage
*/
// ============================================================================
// LocalStorage Keys
// ============================================================================
const STORAGE_KEYS = {
USER_ID: 'uid',
AFFILIATE: 'aff',
STATUS: 'status',
} as const
// ============================================================================
// User ID Storage
// ============================================================================
/**
* Save user ID to localStorage
*/
export function saveUserId(userId: number | string): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEYS.USER_ID, String(userId))
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save user ID:', error)
}
}
/**
* Get user ID from localStorage
*/
export function getUserId(): string | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage.getItem(STORAGE_KEYS.USER_ID)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get user ID:', error)
return null
}
}
/**
* Remove user ID from localStorage
*/
export function removeUserId(): void {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(STORAGE_KEYS.USER_ID)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to remove user ID:', error)
}
}
// ============================================================================
// Affiliate Code Storage
// ============================================================================
/**
* Get affiliate code from localStorage
*/
export function getAffiliateCode(): string {
if (typeof window === 'undefined') return ''
try {
return window.localStorage.getItem(STORAGE_KEYS.AFFILIATE) ?? ''
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get affiliate code:', error)
return ''
}
}
/**
* Save affiliate code to localStorage
*/
export function saveAffiliateCode(code: string): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEYS.AFFILIATE, code)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save affiliate code:', error)
}
}
+62
View File
@@ -0,0 +1,62 @@
import { BACKUP_CODE_REGEX, OTP_REGEX } from '../constants'
/**
* Validation utilities for authentication forms
*/
// ============================================================================
// OTP Validation
// ============================================================================
/**
* Validate OTP code (6 digits)
*/
export function isValidOTP(code: string): boolean {
return OTP_REGEX.test(code)
}
/**
* Validate backup code (XXXX-XXXX format)
*/
export function isValidBackupCode(code: string): boolean {
return BACKUP_CODE_REGEX.test(code)
}
/**
* Format backup code with hyphen (XXXX-XXXX)
*/
export function formatBackupCode(value: string): string {
// Remove all non-alphanumeric characters and convert to uppercase
let cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
// Limit to 8 characters
if (cleaned.length > 8) {
cleaned = cleaned.slice(0, 8)
}
// Add hyphen after 4th character
if (cleaned.length > 4) {
return cleaned.slice(0, 4) + '-' + cleaned.slice(4)
}
return cleaned
}
/**
* Remove hyphens from backup code before sending to server
*/
export function cleanBackupCode(code: string): string {
return code.replace(/-/g, '')
}
// ============================================================================
// Email Validation
// ============================================================================
/**
* Basic email validation
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
@@ -0,0 +1,215 @@
import { useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
import { login2fa } from '@/features/auth/api'
import {
otpFormSchema,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
} from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { saveUserId } from '@/features/auth/lib/storage'
import {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
} from '@/features/auth/lib/validation'
import type { User } from '@/features/users/types'
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
export function OtpForm({ className, ...props }: OtpFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [useBackupCode, setUseBackupCode] = useState(false)
const { auth } = useAuthStore()
const { redirectToLogin } = useAuthRedirect()
const form = useForm<z.infer<typeof otpFormSchema>>({
resolver: zodResolver(otpFormSchema),
defaultValues: { otp: '' },
})
const otp = form.watch('otp')
async function onSubmit(data: z.infer<typeof otpFormSchema>) {
// Validate based on mode
if (useBackupCode) {
if (!isValidBackupCode(data.otp)) {
toast.error(t('Backup code must be in format XXXX-XXXX'))
return
}
} else {
if (!isValidOTP(data.otp)) {
toast.error(t('Verification code must be 6 digits'))
return
}
}
setIsLoading(true)
try {
// Remove all hyphens from backup code before sending to backend
const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp
const res = await login2fa({ code })
if (!res.success) {
toast.error(res.message || t('Invalid code'))
return
}
// Handle user data from 2FA login response
const userData = res.data
if (!userData) {
throw new Error('No user data received from login')
}
// Update auth store
auth.setUser(userData as User)
// Store user ID in localStorage for compatibility
if (userData.id) {
saveUserId(userData.id)
}
toast.success(t('Signed in'))
redirectToLogin() // This will redirect to dashboard via the redirect logic
} catch (error) {
// eslint-disable-next-line no-console
console.error('2FA verification error:', error)
const errorMessage =
error instanceof Error ? error.message : t('Verification failed')
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
function handleToggleMode() {
setUseBackupCode(!useBackupCode)
form.setValue('otp', '')
}
function handleBackToLogin() {
redirectToLogin()
}
const isFormValid = useBackupCode
? otp.length >= BACKUP_CODE_LENGTH
: otp.length >= OTP_LENGTH
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel>
{useBackupCode ? t('Backup Code') : t('Verification Code')}
</FormLabel>
<FormControl>
{useBackupCode ? (
<Input
placeholder={t('Enter backup code (e.g., CAWD-OQDV)')}
{...field}
maxLength={BACKUP_CODE_LENGTH}
autoComplete='off'
className='font-mono uppercase'
onChange={(e) => {
const formatted = formatBackupCode(e.target.value)
field.onChange(formatted)
}}
/>
) : (
<InputOTP
maxLength={OTP_LENGTH}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)}
</FormControl>
<FormDescription className='text-muted-foreground text-xs'>
{useBackupCode
? t('Each backup code can only be used once.')
: t('Verification code updates every 30 seconds.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2 w-full' disabled={!isFormValid || isLoading}>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')}
</Button>
<div className='flex items-center justify-center gap-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleToggleMode}
>
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
</Button>
<span className='text-muted-foreground'>·</span>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleBackToLogin}
>
{t('Back to login')}
</Button>
</div>
</form>
</Form>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AuthLayout } from '../auth-layout'
import { OtpForm } from './components/otp-form'
export function Otp() {
const { t } = useTranslation()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-3'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Two-factor Authentication')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Please enter the authentication code.')}
</p>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Session expired?')}{' '}
<Link
to='/sign-in'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Re-login')}
</Link>
.
</p>
</div>
<OtpForm />
</div>
</AuthLayout>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { api } from '@/lib/api'
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
const res = await api.get<ApiResponse<PasskeyStatus>>('/api/user/passkey')
return res.data
}
export async function beginPasskeyRegistration(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/register/begin'
)
return res.data
}
export async function finishPasskeyRegistration(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/register/finish',
payload
)
return res.data
}
export async function deletePasskey(): Promise<ApiResponse> {
const res = await api.delete<ApiResponse>('/api/user/passkey')
return res.data
}
export async function beginPasskeyLogin(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/login/begin'
)
return res.data
}
export async function finishPasskeyLogin(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/login/finish',
payload
)
return res.data
}
export async function beginPasskeyVerification(): Promise<
ApiResponse<PasskeyOptionsPayload>
> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/verify/begin'
)
return res.data
}
export async function finishPasskeyVerification(
payload: Record<string, unknown>
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/verify/finish',
payload
)
return res.data
}
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import {
buildRegistrationResult,
createCredential,
isPasskeySupported as detectPasskeySupport,
prepareCredentialCreationOptions,
} from '@/lib/passkey'
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
} from '../api'
import type { PasskeyStatus } from '../types'
interface UsePasskeyManagementOptions {
onStatusChange?: (status: PasskeyStatus | null) => void
}
export function usePasskeyManagement(
options: UsePasskeyManagementOptions = {}
) {
const { onStatusChange } = options
const [status, setStatus] = useState<PasskeyStatus | null>(null)
const [loading, setLoading] = useState(true)
const [registering, setRegistering] = useState(false)
const [removing, setRemoving] = useState(false)
const [supported, setSupported] = useState(false)
const fetchStatus = useCallback(async () => {
try {
setLoading(true)
const res = await getPasskeyStatus()
if (res.success) {
setStatus(res.data ?? null)
onStatusChange?.(res.data ?? null)
} else {
setStatus(null)
toast.error(res.message || i18next.t('Failed to load Passkey status'))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Failed to fetch status', error)
toast.error(i18next.t('Failed to load Passkey status'))
setStatus(null)
} finally {
setLoading(false)
}
}, [onStatusChange])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
useEffect(() => {
detectPasskeySupport()
.then(setSupported)
.catch(() => setSupported(false))
}, [])
const register = useCallback(async () => {
if (!supported) {
toast.error(i18next.t('This device does not support Passkey'))
return false
}
if (!navigator?.credentials) {
toast.error(i18next.t('Passkey is not supported in this environment'))
return false
}
setRegistering(true)
try {
const beginResponse = await beginPasskeyRegistration()
if (!beginResponse.success) {
toast.error(
beginResponse.message ||
i18next.t('Failed to start Passkey registration')
)
return false
}
const publicKey = prepareCredentialCreationOptions(
beginResponse.data?.options ?? beginResponse.data
)
const credential = (await createCredential(
publicKey
)) as PublicKeyCredential | null
if (!credential) {
toast.error(i18next.t('Passkey registration was cancelled'))
return false
}
const attestation = buildRegistrationResult(credential)
if (!attestation) {
toast.error(i18next.t('Invalid Passkey registration response'))
return false
}
const finishResponse = await finishPasskeyRegistration(attestation)
if (!finishResponse.success) {
toast.error(
finishResponse.message || i18next.t('Failed to register Passkey')
)
return false
}
toast.success(i18next.t('Passkey registered successfully'))
await fetchStatus()
return true
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(i18next.t('Passkey registration was cancelled'))
return false
}
// eslint-disable-next-line no-console
console.error('[Passkey] Registration error', error)
toast.error(
error instanceof Error
? error.message
: i18next.t('Failed to register Passkey')
)
return false
} finally {
setRegistering(false)
}
}, [supported, fetchStatus])
const remove = useCallback(async () => {
setRemoving(true)
try {
const res = await deletePasskey()
if (!res.success) {
toast.error(res.message || i18next.t('Failed to remove Passkey'))
return false
}
toast.success(i18next.t('Passkey removed successfully'))
await fetchStatus()
return true
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Removal error', error)
toast.error(i18next.t('Failed to remove Passkey'))
return false
} finally {
setRemoving(false)
}
}, [fetchStatus])
const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
return {
status,
loading,
registering,
removing,
supported,
enabled,
lastUsed,
fetchStatus,
register,
remove,
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './api'
export * from './types'
export * from './hooks/use-passkey-management'
+20
View File
@@ -0,0 +1,20 @@
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export interface PasskeyStatus {
enabled: boolean
last_used_at?: string | null
backup_eligible?: boolean
backup_state?: boolean
[key: string]: unknown
}
export interface PasskeyOptionsPayload {
options?: unknown
publicKey?: unknown
response?: unknown
Response?: unknown
}
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { CheckIcon, CopyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { useCountdown } from '@/hooks/use-countdown'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { AuthLayout } from '../auth-layout'
export type ResetPasswordSearchParams = {
email?: string
token?: string
}
type ResetPasswordConfirmProps = ResetPasswordSearchParams
export function ResetPasswordConfirm({
email,
token,
}: ResetPasswordConfirmProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [newPassword, setNewPassword] = useState('')
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const {
secondsLeft,
isActive,
start: startCountdown,
} = useCountdown({ initialSeconds: 30 })
const isValidResetLink = Boolean(email && token)
async function handleSubmit() {
if (!isValidResetLink || !email || !token) {
toast.error(t('Invalid reset link, please request a new password reset'))
return
}
startCountdown()
setLoading(true)
try {
const res = await api.post('/api/user/reset', { email, token }, {
skipBusinessError: true,
} as Record<string, unknown>)
if (res?.data?.success) {
const password = res.data.data
setNewPassword(password)
const copySuccess = await copyToClipboard(password)
if (copySuccess) {
toast.success(
t('Password reset and copied to clipboard: {{password}}', {
password,
})
)
} else {
toast.success(t('Password reset: {{password}}', { password }))
}
}
} catch {
// Errors handled by global interceptor
} finally {
setLoading(false)
}
}
async function handleCopy() {
if (!newPassword) return
const copySuccess = await copyToClipboard(newPassword)
if (copySuccess) {
setCopied(true)
toast.success(
t('Password copied to clipboard: {{password}}', {
password: newPassword,
})
)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Reset password')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{newPassword
? 'Your password has been reset successfully'
: 'Confirm the reset request to generate a new password.'}
</p>
</div>
<div className='space-y-4'>
{!isValidResetLink && (
<Alert variant='destructive'>
<AlertDescription>
{t('Invalid reset link, please request a new password reset.')}
</AlertDescription>
</Alert>
)}
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email')}</Label>
<Input
id='email'
type='email'
value={email || ''}
disabled
placeholder={t('Waiting for email...')}
/>
</div>
{newPassword && (
<div className='space-y-2'>
<Label htmlFor='password'>{t('New password')}</Label>
<div className='flex gap-2'>
<Input
id='password'
value={newPassword}
disabled
className='font-mono'
/>
<Button
type='button'
size='icon'
variant='outline'
onClick={handleCopy}
>
{copied ? (
<CheckIcon className='h-4 w-4' />
) : (
<CopyIcon className='h-4 w-4' />
)}
</Button>
</div>
<p className='text-muted-foreground text-xs'>
{t('Password has been copied to clipboard')}
</p>
</div>
)}
<Button
className='w-full'
onClick={
newPassword
? () => navigate({ to: '/sign-in', replace: true })
: handleSubmit
}
disabled={
newPassword ? false : loading || isActive || !isValidResetLink
}
>
{newPassword
? 'Return to login'
: isActive
? `Retry (${secondsLeft}s)`
: 'Confirm reset password'}
</Button>
{!newPassword && (
<Button
variant='link'
className='w-full'
onClick={() => navigate({ to: '/sign-in', replace: true })}
>
{t('Back to login')}
</Button>
)}
</div>
</div>
</AuthLayout>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { api, get2FAStatus } from '@/lib/api'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import {
beginPasskeyVerification,
finishPasskeyVerification,
getPasskeyStatus,
} from '../passkey'
import type { VerificationMethod, VerificationMethods } from './types'
/**
* Fetch available verification methods for the current user.
*/
export async function checkVerificationMethods(): Promise<VerificationMethods> {
try {
const [twoFAResponse, passkeyResponse, passkeySupported] =
await Promise.all([
get2FAStatus(),
getPasskeyStatus(),
detectPasskeySupport(),
])
const has2FA =
Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled)
const hasPasskey =
Boolean(passkeyResponse?.success) &&
Boolean(passkeyResponse?.data?.enabled)
return {
has2FA,
hasPasskey,
passkeySupported,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Secure Verification] Failed to check methods', error)
return {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
}
}
/**
* Execute a verification flow based on the method type.
*/
export async function verify(
method: VerificationMethod,
code?: string
): Promise<void> {
switch (method) {
case '2fa':
return verifyTwoFA(code)
case 'passkey':
return verifyPasskey()
default:
throw new Error(`Unsupported verification method: ${method}`)
}
}
/**
* Perform 2FA verification flow.
*/
async function verifyTwoFA(code?: string | null): Promise<void> {
const trimmed = code?.trim()
if (!trimmed) {
throw new Error('Please enter the verification code or backup code')
}
const res = await api.post('/api/verify', {
method: '2fa',
code: trimmed,
})
if (!res.data?.success) {
throw new Error(res.data?.message || 'Verification failed')
}
}
/**
* Perform Passkey verification flow.
*/
async function verifyPasskey(): Promise<void> {
if (typeof navigator === 'undefined' || !navigator.credentials) {
throw new Error('Passkey verification is not supported in this environment')
}
try {
const beginResponse = await beginPasskeyVerification()
if (!beginResponse.success) {
throw new Error(beginResponse.message || 'Failed to start verification')
}
const publicKey = prepareCredentialRequestOptions(
beginResponse.data?.options ?? beginResponse.data
)
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
throw new Error('Passkey verification was cancelled')
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error('Unable to build Passkey assertion')
}
const finishResponse = await finishPasskeyVerification(assertion)
if (!finishResponse.success) {
throw new Error(finishResponse.message || 'Passkey verification failed')
}
const verifyResponse = await api.post('/api/verify', {
method: 'passkey',
})
if (!verifyResponse.data?.success) {
throw new Error(
verifyResponse.data?.message || 'Failed to complete verification'
)
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new Error('Passkey verification was cancelled or timed out', {
cause: error,
})
}
if (error instanceof DOMException && error.name === 'InvalidStateError') {
throw new Error(
'Passkey verification is not available in the current state',
{ cause: error }
)
}
throw error
}
}
@@ -0,0 +1,194 @@
import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type {
SecureVerificationState,
VerificationMethod,
VerificationMethods,
} from '../types'
interface SecureVerificationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
methods: VerificationMethods
state: SecureVerificationState
onVerify: (method: VerificationMethod, code?: string) => void | Promise<void>
onCancel: () => void
onCodeChange: (code: string) => void
onMethodChange: (method: VerificationMethod) => void
}
export function SecureVerificationDialog({
open,
onOpenChange,
methods,
state,
onVerify,
onCancel,
onCodeChange,
onMethodChange,
}: SecureVerificationDialogProps) {
const { t } = useTranslation()
const availableTabs: VerificationMethod[] = useMemo(() => {
const tabs: VerificationMethod[] = []
if (methods.has2FA) tabs.push('2fa')
if (methods.hasPasskey && methods.passkeySupported) tabs.push('passkey')
return tabs
}, [methods])
const activeMethod =
state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null)
const title =
state.title ??
(availableTabs.length
? 'Additional verification required'
: 'Verification unavailable')
const description =
state.description ??
(availableTabs.length
? 'Confirm your identity before accessing this sensitive action.'
: 'Enable Two-factor Authentication or Passkey in your profile settings to continue.')
const handleVerify = () => {
if (!activeMethod) return
const payload = activeMethod === '2fa' ? state.code : undefined
onVerify(activeMethod, payload)
}
const verifyDisabled =
state.loading ||
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<div className='flex-1 overflow-y-auto px-6 py-5'>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-full'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
import {
extractVerificationInfo,
isVerificationRequiredError,
} from '@/lib/secure-verification'
import { checkVerificationMethods, verify } from '../api'
import type {
SecureVerificationState,
StartVerificationOptions,
UseSecureVerificationOptions,
VerificationMethod,
VerificationMethods,
} from '../types'
type ApiCall = (() => Promise<unknown>) | null
interface InternalState extends SecureVerificationState {
apiCall: ApiCall
}
const defaultMethods: VerificationMethods = {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
const initialState: InternalState = {
method: null,
loading: false,
code: '',
title: undefined,
description: undefined,
apiCall: null,
}
export function useSecureVerification(
options: UseSecureVerificationOptions = {}
) {
const { onSuccess, onError, successMessage, autoReset = true } = options
const [methods, setMethods] = useState<VerificationMethods>(defaultMethods)
const [state, setState] = useState<InternalState>(initialState)
const [open, setOpen] = useState(false)
const fetchVerificationMethods = useCallback(async () => {
const result = await checkVerificationMethods()
setMethods(result)
return result
}, [])
useEffect(() => {
fetchVerificationMethods()
}, [fetchVerificationMethods])
const reset = useCallback(() => {
setState(initialState)
setOpen(false)
}, [])
const startVerification = useCallback(
async (
apiCall: () => Promise<unknown>,
config: StartVerificationOptions = {}
) => {
const { preferredMethod, title, description } = config
const availableMethods = await fetchVerificationMethods()
if (!availableMethods.has2FA && !availableMethods.hasPasskey) {
toast.error(
i18next.t(
'Please enable Two-factor Authentication or Passkey before proceeding'
)
)
onError?.(
new Error(
'No verification methods available. Enable 2FA or Passkey to continue.'
)
)
return false
}
let defaultMethod: VerificationMethod | null = preferredMethod ?? null
if (!defaultMethod) {
if (availableMethods.hasPasskey && availableMethods.passkeySupported) {
defaultMethod = 'passkey'
} else if (availableMethods.has2FA) {
defaultMethod = '2fa'
}
}
setState((prev) => ({
...prev,
apiCall,
method: defaultMethod,
title,
description,
}))
setOpen(true)
return true
},
[fetchVerificationMethods, onError]
)
const executeVerification = useCallback(
async (method?: VerificationMethod, code?: string) => {
if (!state.apiCall) {
toast.error(i18next.t('Verification is not configured properly'))
return
}
const actualMethod = method ?? state.method
if (!actualMethod) {
toast.error(i18next.t('Select a verification method first'))
return
}
setState((prev) => ({ ...prev, loading: true }))
try {
await verify(actualMethod, code ?? state.code)
const result = await state.apiCall()
if (successMessage) {
toast.success(successMessage)
}
onSuccess?.(result, actualMethod)
if (autoReset) {
reset()
}
return result
} catch (error) {
const message =
error instanceof Error
? error.message
: i18next.t('Verification failed')
toast.error(message)
onError?.(error)
throw error
} finally {
setState((prev) => ({ ...prev, loading: false }))
}
},
[state, successMessage, onSuccess, onError, autoReset, reset]
)
const setCode = useCallback((code: string) => {
setState((prev) => ({ ...prev, code }))
}, [])
const switchMethod = useCallback((method: VerificationMethod) => {
setState((prev) => ({ ...prev, method, code: '' }))
}, [])
const cancel = useCallback(() => {
reset()
}, [reset])
const withVerification = useCallback(
async (
apiCall: () => Promise<unknown>,
config: StartVerificationOptions = {}
) => {
try {
return await apiCall()
} catch (error) {
if (isVerificationRequiredError(error)) {
const info = extractVerificationInfo(error)
toast.info(info.message)
await startVerification(apiCall, config)
return null
}
throw error
}
},
[startVerification]
)
const canUseMethod = useCallback(
(method: VerificationMethod) => {
if (method === '2fa') return methods.has2FA
if (method === 'passkey') {
return methods.hasPasskey && methods.passkeySupported
}
return false
},
[methods]
)
const recommendedMethod = useMemo<VerificationMethod | null>(() => {
if (methods.hasPasskey && methods.passkeySupported) return 'passkey'
if (methods.has2FA) return '2fa'
return null
}, [methods])
return {
open,
setOpen,
methods,
state,
startVerification,
executeVerification,
cancel,
reset,
setCode,
switchMethod,
withVerification,
fetchVerificationMethods,
canUseMethod,
recommendedMethod,
hasAnyMethod: methods.has2FA || methods.hasPasskey,
isLoading: state.loading,
currentMethod: state.method,
code: state.code,
}
}
@@ -0,0 +1,4 @@
export * from './api'
export * from './types'
export * from './hooks/use-secure-verification'
export * from './components/secure-verification-dialog'
@@ -0,0 +1,28 @@
export type VerificationMethod = '2fa' | 'passkey'
export interface VerificationMethods {
has2FA: boolean
hasPasskey: boolean
passkeySupported: boolean
}
export interface SecureVerificationState {
method: VerificationMethod | null
loading: boolean
code: string
title?: string
description?: string
}
export interface UseSecureVerificationOptions {
onSuccess?: (result: unknown, method: VerificationMethod) => void
onError?: (error: unknown) => void
successMessage?: string
autoReset?: boolean
}
export interface StartVerificationOptions {
preferredMethod?: VerificationMethod
title?: string
description?: string
}
@@ -0,0 +1,435 @@
import { useEffect, useMemo, useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { Loader2, LogIn, KeyRound } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
import { loginFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import type { AuthFormProps } from '@/features/auth/types'
export function UserAuthForm({
className,
redirectTo,
...props
}: AuthFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [wechatCode, setWeChatCode] = useState('')
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [passkeySupported, setPasskeySupported] = useState(false)
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const legalConsentErrorMessage = t('Please agree to the legal terms first')
const loginFailedMessage = t('Login failed')
const { status } = useStatus()
const passkeyLoginEnabled = Boolean(
status?.passkey_login ?? status?.data?.passkey_login
)
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
const passkeyButtonDisabled =
isPasskeyLoading ||
!passkeySupported ||
(requiresLegalConsent && !agreedToLegal)
const hasWeChatLogin = Boolean(status?.wechat_login)
useEffect(() => {
if (requiresLegalConsent) {
setAgreedToLegal(false)
} else {
setAgreedToLegal(true)
}
}, [requiresLegalConsent])
useEffect(() => {
detectPasskeySupport()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false))
}, [])
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
username: '',
password: '',
},
})
const wechatQrCodeUrl = useMemo(() => {
return (
status?.wechat_qrcode ||
status?.wechat_qr_code ||
status?.wechat_qrcode_image_url ||
status?.wechat_qr_code_image_url ||
status?.wechat_account_qrcode_image_url ||
status?.WeChatAccountQRCodeImageURL ||
status?.data?.wechat_qrcode ||
status?.data?.WeChatAccountQRCodeImageURL ||
''
)
}, [status])
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!validateTurnstile()) return
setIsLoading(true)
try {
const res = await login({
username: data.username,
password: data.password,
turnstile: turnstileToken,
})
if (res.success) {
if (res.data?.require_2fa) {
redirectTo2FA()
return
}
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
toast.success(t('Welcome back!'))
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
const handleOpenWeChatDialog = () => {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
setIsWeChatDialogOpen(true)
}
const handleWeChatDialogChange = (open: boolean) => {
setIsWeChatDialogOpen(open)
if (!open) {
setWeChatCode('')
setIsWeChatSubmitting(false)
}
}
async function handleWeChatLogin() {
if (!wechatCode.trim()) {
toast.error(t('Please enter the verification code'))
return
}
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success) {
await handleLoginSuccess(res.data as { id?: number } | null, redirectTo)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} else {
toast.error(res?.message || loginFailedMessage)
}
} catch (_error) {
toast.error(loginFailedMessage)
} finally {
setIsWeChatSubmitting(false)
}
}
async function handlePasskeyLogin() {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
if (!passkeySupported) {
toast.error(t('Passkey is not supported on this device'))
return
}
if (!navigator?.credentials) {
toast.error(t('Passkey is not available in this browser'))
return
}
setIsPasskeyLoading(true)
try {
const begin = await beginPasskeyLogin()
if (!begin.success) {
throw new Error(begin.message || t('Failed to start Passkey login'))
}
const publicKey = prepareCredentialRequestOptions(
begin.data?.options ?? begin.data
)
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
toast.info(t('Passkey login was cancelled'))
return
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(t('Invalid Passkey response'))
}
const finish = await finishPasskeyLogin(assertion)
if (!finish.success) {
throw new Error(finish.message || t('Failed to complete Passkey login'))
}
if (!finish.data) {
throw new Error(t('Missing user data from Passkey login response'))
}
await handleLoginSuccess(
finish.data as { id?: number } | null,
redirectTo
)
toast.success(t('Signed in with Passkey'))
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(t('Passkey login was cancelled or timed out'))
} else if (error instanceof Error) {
toast.error(error.message)
} else {
toast.error(t('Passkey login failed'))
}
} finally {
setIsPasskeyLoading(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
{/* Username Field */}
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username or Email')}</FormLabel>
<FormControl>
<Input
placeholder={t('Enter your username or email')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password Field */}
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<PasswordInput placeholder={t('Enter password')} {...field} />
</FormControl>
<FormMessage />
<Link
to='/forgot-password'
className='text-muted-foreground absolute end-0 -top-0.5 text-sm font-medium hover:opacity-75'
>
{t('Forgot password?')}
</Link>
</FormItem>
)}
/>
{/* Submit Button */}
<Button
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
{t('Sign in')}
</Button>
{/* Turnstile */}
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
<LegalConsent
status={status}
checked={agreedToLegal}
onCheckedChange={setAgreedToLegal}
className='mt-1'
/>
{passkeyLoginEnabled && (
<div className='mt-2 space-y-1'>
<Button
type='button'
variant='outline'
disabled={passkeyButtonDisabled}
onClick={handlePasskeyLogin}
className='h-11 w-full justify-center gap-2 rounded-lg'
>
{isPasskeyLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<KeyRound className='h-4 w-4' />
)}
{t('Sign in with Passkey')}
</Button>
{!passkeySupported && (
<p className='text-muted-foreground text-xs'>
{t('Passkey is not supported on this device.')}
</p>
)}
</div>
)}
{/* OAuth Providers */}
<OAuthProviders
status={status}
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
isWeChatLoading={isWeChatSubmitting}
/>
</form>
{hasWeChatLogin && (
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => handleWeChatDialogChange(false)}
disabled={isWeChatSubmitting}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleWeChatLogin}
disabled={
isWeChatSubmitting ||
!wechatCode.trim() ||
(requiresLegalConsent && !agreedToLegal)
}
className='gap-2'
>
{isWeChatSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { Link, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { AuthLayout } from '../auth-layout'
import { TermsFooter } from '../components/terms-footer'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn() {
const { t } = useTranslation()
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
const { status } = useStatus()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Sign in')}
</h2>
{!status?.self_use_mode_enabled && (
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t("Don't have an account?")}{' '}
<Link
to='/sign-up'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign up')}
</Link>
.
</p>
)}
</div>
<UserAuthForm redirectTo={redirect} />
<TermsFooter
variant='sign-in'
status={status}
className='text-center'
/>
</div>
</AuthLayout>
)
}
@@ -0,0 +1,412 @@
import { useEffect, useMemo, useState } from 'react'
import type { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
import { registerFormSchema } from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { getAffiliateCode } from '@/features/auth/lib/storage'
export function SignUpForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [verificationCode, setVerificationCode] = useState('')
const [agreedToLegal, setAgreedToLegal] = useState(false)
const [wechatCode, setWeChatCode] = useState('')
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
const legalConsentErrorMessage = t('Please agree to the legal terms first')
const { status } = useStatus()
const {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { redirectToLogin, handleLoginSuccess } = useAuthRedirect()
const {
isSending: isSendingCode,
secondsLeft,
isActive,
sendCode,
} = useEmailVerification({
turnstileToken,
validateTurnstile,
})
const form = useForm<z.infer<typeof registerFormSchema>>({
resolver: zodResolver(registerFormSchema),
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
},
})
const emailValue = form.watch('email')
const emailVerificationRequired = !!status?.email_verification
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
const oauthRegisterEnabled =
status?.oauth_register_enabled ??
status?.data?.oauth_register_enabled ??
true
const hasWeChatLogin = Boolean(status?.wechat_login)
const wechatQrCodeUrl = useMemo(() => {
return (
status?.wechat_qrcode ||
status?.wechat_qr_code ||
status?.wechat_qrcode_image_url ||
status?.wechat_qr_code_image_url ||
status?.wechat_account_qrcode_image_url ||
status?.WeChatAccountQRCodeImageURL ||
status?.data?.wechat_qrcode ||
status?.data?.WeChatAccountQRCodeImageURL ||
''
)
}, [status])
useEffect(() => {
if (requiresLegalConsent) {
setAgreedToLegal(false)
} else {
setAgreedToLegal(true)
}
}, [requiresLegalConsent])
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
// Validate email verification if required
if (emailVerificationRequired) {
if (!data.email) {
toast.error(t('Please enter your email'))
return
}
if (!verificationCode) {
toast.error(t('Please enter the verification code'))
return
}
}
setIsLoading(true)
try {
const res = await register({
username: data.username,
password: data.password,
email: data.email || undefined,
verification_code: verificationCode || undefined,
aff: getAffiliateCode(),
turnstile: turnstileToken,
})
if (res?.success) {
toast.success(t('Account created! Please sign in'))
redirectToLogin()
}
} catch (_error) {
// Errors are handled by global interceptor
} finally {
setIsLoading(false)
}
}
async function handleSendVerificationCode() {
await sendCode(emailValue || '')
}
const handleOpenWeChatDialog = () => {
if (requiresLegalConsent && !agreedToLegal) {
toast.error(legalConsentErrorMessage)
return
}
setIsWeChatDialogOpen(true)
}
const handleWeChatDialogChange = (open: boolean) => {
setIsWeChatDialogOpen(open)
if (!open) {
setWeChatCode('')
setIsWeChatSubmitting(false)
}
}
async function handleWeChatLogin() {
if (!wechatCode.trim()) {
toast.error(t('Please enter the verification code'))
return
}
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success) {
await handleLoginSuccess(res.data as { id?: number } | null)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} else {
toast.error(res?.message || t('Login failed'))
}
} catch (_error) {
toast.error(t('Login failed'))
} finally {
setIsWeChatSubmitting(false)
}
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
{/* Username Field */}
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username')}</FormLabel>
<FormControl>
<Input placeholder={t('Enter your username')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Password Field */}
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t('Enter password (8-20 characters)')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Confirm Password Field */}
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Confirm password')}</FormLabel>
<FormControl>
<PasswordInput placeholder={t('Confirm password')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email Verification Section */}
{emailVerificationRequired && (
<>
{/* Email Field */}
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Email (required for verification)')}
</FormLabel>
<FormControl>
<Input
placeholder={t('name@example.com')}
type='email'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Verification Code Field */}
<div className='flex items-end gap-2'>
<div className='flex-1'>
<Input
placeholder={t('Verification code')}
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
</div>
<Button
variant='outline'
type='button'
disabled={isLoading || isSendingCode || isActive || !emailValue}
onClick={handleSendVerificationCode}
>
{isActive ? (
t('Resend ({{seconds}}s)', { seconds: secondsLeft })
) : isSendingCode ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
t('Send code')
)}
</Button>
</div>
{/* Turnstile */}
{isTurnstileEnabled && (
<div className='mt-2'>
<Turnstile
siteKey={turnstileSiteKey}
onVerify={setTurnstileToken}
/>
</div>
)}
</>
)}
<LegalConsent
status={status}
checked={agreedToLegal}
onCheckedChange={setAgreedToLegal}
className='mt-1'
/>
{/* Submit Button */}
<Button
className='mt-2 w-full justify-center gap-2'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Create account')}
</Button>
{oauthRegisterEnabled && (
<OAuthProviders
status={status}
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
isWeChatLoading={isWeChatSubmitting}
className='pt-2'
/>
)}
</form>
{hasWeChatLogin && (
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => handleWeChatDialogChange(false)}
disabled={isWeChatSubmitting}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleWeChatLogin}
disabled={
isWeChatSubmitting ||
!wechatCode.trim() ||
(requiresLegalConsent && !agreedToLegal)
}
className='gap-2'
>
{isWeChatSubmitting ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { AuthLayout } from '../auth-layout'
import { TermsFooter } from '../components/terms-footer'
import { SignUpForm } from './components/sign-up-form'
export function SignUp() {
const { t } = useTranslation()
const { status } = useStatus()
return (
<AuthLayout>
<div className='w-full space-y-8'>
<div className='space-y-2'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Create an account')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Already have an account?')}{' '}
<Link
to='/sign-in'
className='hover:text-primary font-medium underline underline-offset-4'
>
{t('Sign in')}
</Link>
.
</p>
</div>
<SignUpForm />
<TermsFooter
variant='sign-up'
status={status}
className='text-center'
/>
</div>
</AuthLayout>
)
}
+186
View File
@@ -0,0 +1,186 @@
import type { User } from '@/features/users/types'
// ============================================================================
// API Payloads
// ============================================================================
export interface LoginPayload {
username: string
password: string
turnstile?: string
}
export interface TwoFAPayload {
code: string
}
export interface RegisterPayload {
username: string
password: string
email?: string
verification_code?: string
aff?: string
turnstile?: string
}
export interface PasswordResetPayload {
email: string
turnstile?: string
}
export interface EmailVerificationPayload {
email: string
turnstile?: string
}
export interface BindEmailPayload {
email: string
code: string
}
// ============================================================================
// API Responses
// ============================================================================
export interface LoginResponse {
success: boolean
message: string
data?: {
require_2fa?: boolean
id?: number
}
}
export interface Login2FAResponse {
success: boolean
message: string
data?: User
}
export interface ApiResponse {
success: boolean
message: string
data?: unknown
}
// ============================================================================
// System Status
// ============================================================================
export interface SystemStatus {
success?: boolean
message?: string
data?: {
version?: string
system_name?: string
logo?: string
github_oauth?: boolean
github_client_id?: string
discord_oauth?: boolean
discord_client_id?: string
oidc_enabled?: boolean
oidc_authorization_endpoint?: string
oidc_client_id?: string
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
passkey_login?: boolean
wechat_login?: boolean
wechat_qrcode?: string
wechat_qr_code?: string
wechat_qrcode_image_url?: string
wechat_qr_code_image_url?: string
wechat_account_qrcode_image_url?: string
WeChatAccountQRCodeImageURL?: string
turnstile_check?: boolean
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
quota_display_type?: string
usd_exchange_rate?: number
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
register_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
}
// Allow direct access to common properties
version?: string
system_name?: string
logo?: string
github_oauth?: boolean
github_client_id?: string
discord_oauth?: boolean
discord_client_id?: string
oidc_enabled?: boolean
oidc_authorization_endpoint?: string
oidc_client_id?: string
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: boolean
passkey_login?: boolean
wechat_login?: boolean
wechat_qrcode?: string
wechat_qr_code?: string
wechat_qrcode_image_url?: string
wechat_qr_code_image_url?: string
wechat_account_qrcode_image_url?: string
WeChatAccountQRCodeImageURL?: string
turnstile_check?: boolean
turnstile_site_key?: string
email_verification?: boolean
self_use_mode_enabled?: boolean
display_in_currency?: boolean
display_token_stat_enabled?: boolean
quota_per_unit?: number
quota_display_type?: string
usd_exchange_rate?: number
custom_currency_symbol?: string
custom_currency_exchange_rate?: number
demo_site_enabled?: boolean
user_agreement_enabled?: boolean
privacy_policy_enabled?: boolean
oauth_register_enabled?: boolean
register_enabled?: boolean
password_register_enabled?: boolean
custom_oauth_providers?: CustomOAuthProviderInfo[]
[key: string]: unknown
}
// ============================================================================
// OAuth
// ============================================================================
export interface OAuthProvider {
name: string
type: 'github' | 'discord' | 'oidc' | 'linuxdo' | 'telegram' | 'wechat'
enabled: boolean
clientId?: string
authEndpoint?: string
}
export interface CustomOAuthProviderInfo {
id: number
name: string
slug: string
icon: string
client_id: string
authorization_endpoint: string
scopes: string
}
// ============================================================================
// Form Props
// ============================================================================
export interface AuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
redirectTo?: string
}
+548
View File
@@ -0,0 +1,548 @@
import type { AxiosRequestConfig } from 'axios'
import { api } from '@/lib/api'
import { getGroups as getUserGroups } from '@/features/users/api'
import type {
AddChannelRequest,
BatchDeleteParams,
BatchSetTagParams,
Channel,
ChannelBalanceResponse,
ChannelTestResponse,
CopyChannelParams,
CopyChannelResponse,
FetchModelsResponse,
GetChannelResponse,
GetChannelsParams,
GetChannelsResponse,
MultiKeyManageParams,
MultiKeyStatusResponse,
SearchChannelsParams,
SearchChannelsResponse,
TagOperationParams,
} from './types'
// Extended API config types
interface ExtendedApiConfig extends AxiosRequestConfig {
skipBusinessError?: boolean
disableDuplicate?: boolean
}
export type CodexOAuthStartResponse = {
success: boolean
message?: string
data?: {
authorize_url?: string
}
}
export type CodexOAuthCompleteResponse = {
success: boolean
message?: string
data?: {
key?: string
account_id?: string
email?: string
expires_at?: string
last_refresh?: string
}
}
export type CodexUsageResponse = {
success: boolean
message?: string
upstream_status?: number
data?: Record<string, unknown>
}
export type CodexCredentialRefreshResponse = {
success: boolean
message?: string
data?: {
expires_at?: string
last_refresh?: string
account_id?: string
email?: string
channel_id?: number
channel_type?: number
channel_name?: string
}
}
// ============================================================================
// Base Channel CRUD Operations
// ============================================================================
/**
* Get paginated list of channels
*/
export async function getChannels(
params: GetChannelsParams = {}
): Promise<GetChannelsResponse> {
const res = await api.get('/api/channel', { params })
return res.data
}
/**
* Search channels with filters
*/
export async function searchChannels(
params: SearchChannelsParams
): Promise<SearchChannelsResponse> {
const res = await api.get('/api/channel/search', { params })
return res.data
}
/**
* Get single channel by ID
*/
export async function getChannel(id: number): Promise<GetChannelResponse> {
const res = await api.get(`/api/channel/${id}`)
return res.data
}
/**
* Create new channel(s)
* Supports single, batch, and multi-key modes
*/
export async function createChannel(
data: AddChannelRequest
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel', data)
return res.data
}
/**
* Update existing channel
*/
export async function updateChannel(
id: number,
data: Partial<Channel>
): Promise<{ success: boolean; message?: string; data?: Channel }> {
const res = await api.put('/api/channel/', { id, ...data })
return res.data
}
/**
* Delete single channel
*/
export async function deleteChannel(
id: number
): Promise<{ success: boolean; message?: string }> {
const res = await api.delete(`/api/channel/${id}`)
return res.data
}
/**
* Batch delete channels
*/
export async function batchDeleteChannels(
data: BatchDeleteParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post('/api/channel/batch', data)
return res.data
}
/**
* Batch set tag for channels
*/
export async function batchSetChannelTag(
data: BatchSetTagParams
): Promise<{ success: boolean; message?: string; data?: number }> {
const res = await api.post('/api/channel/batch/tag', data)
return res.data
}
// ============================================================================
// Channel Operations
// ============================================================================
/**
* Test channel connectivity
*/
export async function testChannel(
id: number,
params?: { model?: string; endpoint_type?: string; stream?: boolean }
): Promise<ChannelTestResponse> {
const res = await api.get(`/api/channel/test/${id}`, { params })
return res.data
}
/**
* Update channel balance
*/
export async function updateChannelBalance(
id: number
): Promise<ChannelBalanceResponse> {
const res = await api.get(`/api/channel/update_balance/${id}`)
return res.data
}
/**
* Fetch available models from upstream provider
*/
export async function fetchUpstreamModels(
id: number
): Promise<FetchModelsResponse> {
const res = await api.get(`/api/channel/fetch_models/${id}`)
return res.data
}
/**
* Copy/clone a channel
*/
export async function copyChannel(
id: number,
params: CopyChannelParams = {}
): Promise<CopyChannelResponse> {
const res = await api.post(`/api/channel/copy/${id}`, null, { params })
return res.data
}
/**
* Fix channel abilities
*/
export async function fixChannelAbilities(): Promise<{
success: boolean
message?: string
data?: { success: number; fails: number }
}> {
const res = await api.post('/api/channel/fix')
return res.data
}
/**
* Delete all disabled channels
*/
export async function deleteDisabledChannels(): Promise<{
success: boolean
message?: string
data?: number
}> {
const res = await api.delete('/api/channel/disabled')
return res.data
}
/**
* Get channel key (requires 2FA verification)
*/
export async function getChannelKey(
id: number,
code?: string
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const payload = code ? { code } : undefined
const res = await api.post(`/api/channel/${id}/key`, payload)
return res.data
}
// ============================================================================
// Codex Channel Operations
// ============================================================================
export async function startCodexOAuth(): Promise<CodexOAuthStartResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post('/api/channel/codex/oauth/start', {}, config)
return res.data
}
export async function completeCodexOAuth(
input: string
): Promise<CodexOAuthCompleteResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
'/api/channel/codex/oauth/complete',
{ input },
config
)
return res.data
}
export async function refreshCodexCredential(
channelId: number
): Promise<CodexCredentialRefreshResponse> {
const config: ExtendedApiConfig = { skipBusinessError: true }
const res = await api.post(
`/api/channel/${channelId}/codex/refresh`,
{},
config
)
return res.data
}
export async function getCodexUsage(
channelId: number
): Promise<CodexUsageResponse> {
const config: ExtendedApiConfig = {
skipBusinessError: true,
disableDuplicate: true,
}
const res = await api.get(`/api/channel/${channelId}/codex/usage`, config)
return res.data
}
// ============================================================================
// Multi-Key Management
// ============================================================================
/**
* Manage multi-key channel operations
*/
export async function manageMultiKeys(
params: MultiKeyManageParams
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
const res = await api.post('/api/channel/multi_key/manage', params)
return res.data
}
/**
* Get key status for multi-key channel
*/
export async function getMultiKeyStatus(
channelId: number,
page = 1,
pageSize = 50,
status?: number
): Promise<MultiKeyStatusResponse> {
return manageMultiKeys({
channel_id: channelId,
action: 'get_key_status',
page,
page_size: pageSize,
status,
}) as Promise<MultiKeyStatusResponse>
}
/**
* Enable a specific key in multi-key channel
*/
export async function enableMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'enable_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Disable a specific key in multi-key channel
*/
export async function disableMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'disable_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Delete a specific key in multi-key channel
*/
export async function deleteMultiKey(
channelId: number,
keyIndex: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'delete_key',
key_index: keyIndex,
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Enable all keys in multi-key channel
*/
export async function enableAllMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'enable_all_keys',
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Disable all keys in multi-key channel
*/
export async function disableAllMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string }> {
return manageMultiKeys({
channel_id: channelId,
action: 'disable_all_keys',
}) as Promise<{ success: boolean; message?: string }>
}
/**
* Delete all disabled keys in multi-key channel
*/
export async function deleteDisabledMultiKeys(
channelId: number
): Promise<{ success: boolean; message?: string; data?: number }> {
return manageMultiKeys({
channel_id: channelId,
action: 'delete_disabled_keys',
}) as Promise<{ success: boolean; message?: string; data?: number }>
}
// ============================================================================
// Tag Operations
// ============================================================================
/**
* Enable all channels with a specific tag
*/
export async function enableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel/tag/enabled', { tag })
return res.data
}
/**
* Disable all channels with a specific tag
*/
export async function disableTagChannels(
tag: string
): Promise<{ success: boolean; message?: string }> {
const res = await api.post('/api/channel/tag/disabled', { tag })
return res.data
}
/**
* Edit all channels with a specific tag
*/
export async function editTagChannels(
params: TagOperationParams
): Promise<{ success: boolean; message?: string }> {
const res = await api.put('/api/channel/tag', params)
return res.data
}
/**
* Get models for a specific tag
*/
export async function getTagModels(
tag: string
): Promise<{ success: boolean; message?: string; data?: string }> {
const res = await api.get('/api/channel/tag/models', { params: { tag } })
return res.data
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Fetch models from a custom endpoint (for testing before creating channel)
*/
export async function fetchModels(data: {
base_url: string
type: number
key: string
}): Promise<FetchModelsResponse> {
const res = await api.post('/api/channel/fetch_models', data)
return res.data
}
/**
* Delete an Ollama model from a channel
*/
export async function deleteOllamaModel(params: {
channel_id: number
model_name: string
}): Promise<{ success: boolean; message?: string }> {
const res = await api.delete('/api/channel/ollama/delete', { data: params })
return res.data
}
/**
* Test all enabled channels
*/
export async function testAllChannels(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get('/api/channel/test')
return res.data
}
/**
* Update balance for all enabled channels
*/
export async function updateAllChannelsBalance(): Promise<{
success: boolean
message?: string
}> {
const res = await api.get('/api/channel/update_balance')
return res.data
}
/**
* Get all available models
*/
export async function getAllModels(): Promise<{
success: boolean
message?: string
data?: Array<{ id: string; [key: string]: unknown }>
}> {
const res = await api.get('/api/channel/models')
return res.data
}
/**
* Get all enabled models
*/
export async function getEnabledModels(): Promise<{
success: boolean
message?: string
data?: string[]
}> {
const res = await api.get('/api/channel/models_enabled')
return res.data
}
// ============================================================================
// Ollama Utilities
// ============================================================================
/**
* Check Ollama version for a given channel
*/
export async function getOllamaVersion(
channelId: number
): Promise<{ success: boolean; message?: string; data?: { version: string } }> {
const res = await api.get(`/api/channel/ollama/version/${channelId}`)
return res.data
}
// ============================================================================
// Group Management
// ============================================================================
/**
* Get all available groups (re-exported from users API for convenience)
*/
export const getGroups = getUserGroups
// ============================================================================
// Prefill Groups (Model Groups)
// ============================================================================
/**
* Get prefill groups for quick model selection
*/
export async function getPrefillGroups(
type: 'model' | 'group' = 'model'
): Promise<{
success: boolean
message?: string
data?: Array<{ id: number; name: string; items: string | string[] }>
}> {
const res = await api.get('/api/prefill_group', { params: { type } })
return res.data
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
import { useChannels } from './channels-provider'
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
import { EditTagDialog } from './dialogs/edit-tag-dialog'
import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
import { UpstreamUpdateDialog } from './dialogs/upstream-update-dialog'
import { ChannelMutateDrawer } from './drawers/channel-mutate-drawer'
export function ChannelsDialogs() {
const { open, setOpen, currentRow, upstream } = useChannels()
return (
<>
{/* Channel Create/Update Drawer */}
<ChannelMutateDrawer
open={open === 'create-channel' || open === 'update-channel'}
onOpenChange={(v) => !v && setOpen(null)}
currentRow={open === 'update-channel' ? currentRow : null}
/>
{/* Test Channel Dialog */}
<ChannelTestDialog
open={open === 'test-channel'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Balance Query Dialog */}
<BalanceQueryDialog
open={open === 'balance-query'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={open === 'fetch-models'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Ollama Models Dialog */}
<OllamaModelsDialog
open={open === 'ollama-models'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Copy Channel Dialog */}
<CopyChannelDialog
open={open === 'copy-channel'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Multi-Key Management Dialog */}
<MultiKeyManageDialog
open={open === 'multi-key-manage'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Tag Batch Edit Dialog */}
<TagBatchEditDialog
open={open === 'tag-batch-edit'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Edit Tag Dialog */}
<EditTagDialog
open={open === 'edit-tag'}
onOpenChange={(v) => !v && setOpen(null)}
/>
{/* Upstream Model Update Dialog */}
<UpstreamUpdateDialog
open={upstream.showModal}
addModels={upstream.addModels}
removeModels={upstream.removeModels}
preferredTab={upstream.preferredTab}
confirmLoading={upstream.applyLoading}
onConfirm={upstream.applyUpdates}
onCancel={upstream.closeModal}
/>
</>
)
}
@@ -0,0 +1,218 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Plus,
MoreHorizontal,
Settings2,
Trash2,
Tags,
TestTube,
DollarSign,
SortAsc,
RefreshCw,
ArrowUpFromLine,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
handleDeleteAllDisabled,
handleFixAbilities,
handleTestAllChannels,
handleUpdateAllBalances,
} from '../lib'
import { useChannels } from './channels-provider'
export function ChannelsPrimaryButtons() {
const { t } = useTranslation()
const {
setOpen,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
upstream,
} = useChannels()
const queryClient = useQueryClient()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const handleTagModeToggle = (checked: boolean) => {
localStorage.setItem('enable-tag-mode', String(checked))
setEnableTagMode(checked)
}
const handleIdSortToggle = (checked: boolean) => {
localStorage.setItem('channels-id-sort', String(checked))
setIdSort(checked)
}
return (
<>
<div className='flex items-center gap-2'>
{/* Desktop: Toggle switches visible */}
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<Tags className='text-muted-foreground h-4 w-4' />
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
{t('Tag Mode')}
</Label>
<Switch
id='tag-mode'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
/>
</div>
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
<SortAsc className='text-muted-foreground h-4 w-4' />
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
{t('Sort by ID')}
</Label>
<Switch
id='id-sort'
checked={idSort}
onCheckedChange={handleIdSortToggle}
/>
</div>
{/* Create Channel */}
<Button onClick={() => setOpen('create-channel')} size='sm'>
<Plus className='h-4 w-4' />
<span className='max-sm:hidden'>{t('Create Channel')}</span>
<span className='sm:hidden'>{t('Create')}</span>
</Button>
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm'>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{/* Mobile-only: toggle switches */}
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={enableTagMode}
onCheckedChange={handleTagModeToggle}
>
<Tags className='mr-2 h-4 w-4' />
{t('Tag Mode')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='sm:hidden'
checked={idSort}
onCheckedChange={handleIdSortToggle}
>
<SortAsc className='mr-2 h-4 w-4' />
{t('Sort by ID')}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator className='sm:hidden' />
<DropdownMenuItem
onClick={() => {
handleTestAllChannels(queryClient)
}}
>
{t('Test All Channels')}
<DropdownMenuShortcut>
<TestTube className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleUpdateAllBalances(queryClient)
}}
>
{t('Update All Balances')}
<DropdownMenuShortcut>
<DollarSign className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => upstream.detectAllUpdates()}
disabled={upstream.detectAllLoading}
>
{t('Detect All Upstream Updates')}
<DropdownMenuShortcut>
<RefreshCw className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => upstream.applyAllUpdates()}
disabled={upstream.applyAllLoading}
>
{t('Apply All Upstream Updates')}
<DropdownMenuShortcut>
<ArrowUpFromLine className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
handleFixAbilities(queryClient, (_result) => {
// eslint-disable-next-line no-console
console.log('Fix abilities result:', _result)
})
}}
>
{t('Fix Abilities')}
<DropdownMenuShortcut>
<Settings2 className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setShowDeleteDialog(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete All Disabled')}
<DropdownMenuShortcut>
<Trash2 className='h-4 w-4' />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title={t('Delete All Disabled Channels?')}
desc='This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
destructive
handleConfirm={() => {
handleDeleteAllDisabled(queryClient, (_count) => {
// eslint-disable-next-line no-console
console.log(`Deleted ${_count} channels`)
})
setShowDeleteDialog(false)
}}
/>
</>
)
}
@@ -0,0 +1,101 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
import { channelsQueryKeys } from '../lib'
import type { Channel } from '../types'
// ============================================================================
// Types
// ============================================================================
type DialogType =
| 'create-channel'
| 'update-channel'
| 'test-channel'
| 'balance-query'
| 'fetch-models'
| 'ollama-models'
| 'multi-key-manage'
| 'tag-batch-edit'
| 'edit-tag'
| 'copy-channel'
| null
type UpstreamUpdateState = ReturnType<typeof useChannelUpstreamUpdates>
type ChannelsContextType = {
open: DialogType
setOpen: (open: DialogType) => void
currentRow: Channel | null
setCurrentRow: (row: Channel | null) => void
currentTag: string | null
setCurrentTag: (tag: string | null) => void
enableTagMode: boolean
setEnableTagMode: (enabled: boolean) => void
idSort: boolean
setIdSort: (enabled: boolean) => void
upstream: UpstreamUpdateState
}
// ============================================================================
// Context
// ============================================================================
const ChannelsContext = createContext<ChannelsContextType | undefined>(
undefined
)
// ============================================================================
// Provider
// ============================================================================
export function ChannelsProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState<DialogType>(null)
const [currentRow, setCurrentRow] = useState<Channel | null>(null)
const [currentTag, setCurrentTag] = useState<string | null>(null)
const [enableTagMode, setEnableTagMode] = useState(() => {
return localStorage.getItem('enable-tag-mode') === 'true'
})
const [idSort, setIdSort] = useState(() => {
return localStorage.getItem('channels-id-sort') === 'true'
})
const queryClient = useQueryClient()
const refreshChannels = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: channelsQueryKeys.all })
}, [queryClient])
const upstream = useChannelUpstreamUpdates(refreshChannels)
return (
<ChannelsContext.Provider
value={{
open,
setOpen,
currentRow,
setCurrentRow,
currentTag,
setCurrentTag,
enableTagMode,
setEnableTagMode,
idSort,
setIdSort,
upstream,
}}
>
{children}
</ChannelsContext.Provider>
)
}
// ============================================================================
// Hook
// ============================================================================
export function useChannels() {
const context = useContext(ChannelsContext)
if (!context) {
throw new Error('useChannels must be used within ChannelsProvider')
}
return context
}
@@ -0,0 +1,391 @@
import { useState, useMemo, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
flexRender,
getCoreRowModel,
useReactTable,
getExpandedRowModel,
type SortingState,
type VisibilityState,
type ExpandedState,
type Row,
} from '@tanstack/react-table'
import { useDebounce, useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DataTableToolbar,
TableSkeleton,
TableEmpty,
MobileCardList,
} from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { PageFooterPortal } from '@/components/layout'
import { getChannels, searchChannels, getGroups } from '../api'
import {
DEFAULT_PAGE_SIZE,
CHANNEL_STATUS_OPTIONS,
CHANNEL_TYPE_OPTIONS,
} from '../constants'
import {
channelsQueryKeys,
aggregateChannelsByTag,
isTagAggregateRow,
} from '../lib'
import type { Channel } from '../types'
import { useChannelsColumns } from './channels-columns'
import { useChannels } from './channels-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
const route = getRouteApi('/_authenticated/channels/')
export function ChannelsTable() {
const { t } = useTranslation()
const { enableTagMode, idSort } = useChannels()
const isMobile = useMediaQuery('(max-width: 640px)')
// Table state
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
models: false,
tag: false,
})
const [rowSelection, setRowSelection] = useState({})
const [expanded, setExpanded] = useState<ExpandedState>({})
// URL state management
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: DEFAULT_PAGE_SIZE },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'type', searchKey: 'type', type: 'array' },
{ columnId: 'group', searchKey: 'group', type: 'array' },
{ columnId: 'model', searchKey: 'model', type: 'string' },
],
})
// Extract filters from column filters
const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter =
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl =
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
// Local state for immediate input feedback
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
setModelFilterInput(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (debouncedModelFilter !== modelFilterFromUrl) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
? [...filtered, { id: 'model', value: debouncedModelFilter }]
: filtered
})
}
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
const modelFilter = modelFilterFromUrl
// Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
// Fetch groups for filter
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((g) => ({
label: g,
value: g,
})),
[groupsData]
)
// Fetch channels data
// eslint-disable-next-line @tanstack/query/exhaustive-deps
const { data, isLoading, isFetching } = useQuery({
queryKey: channelsQueryKeys.list({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
}),
queryFn: async () => {
if (shouldSearch) {
return searchChannels({
keyword: globalFilter,
model: modelFilter,
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
} else {
return getChannels({
group:
groupFilter.length > 0 && !groupFilter.includes('all')
? groupFilter[0]
: undefined,
status:
statusFilter.length > 0 && !statusFilter.includes('all')
? statusFilter[0]
: undefined,
type:
typeFilter.length > 0 && !typeFilter.includes('all')
? Number(typeFilter[0])
: undefined,
tag_mode: enableTagMode,
id_sort: idSort,
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
})
}
},
placeholderData: (previousData) => previousData,
})
// Apply tag aggregation if tag mode is enabled
const channels = useMemo(() => {
const rawChannels = data?.data?.items || []
if (enableTagMode && rawChannels.length > 0) {
return aggregateChannelsByTag(rawChannels)
}
return rawChannels
}, [data, enableTagMode])
const totalCount = data?.data?.total || 0
const typeCounts = data?.data?.type_counts
// Columns configuration
const columns = useChannelsColumns()
// React Table instance
const table = useReactTable({
data: channels,
columns,
pageCount: Math.ceil(totalCount / pagination.pageSize),
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
expanded,
globalFilter,
},
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange,
onExpandedChange: setExpanded,
onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
})
// Ensure page is in range when total count changes
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
// Prepare filter options (option.label are i18n keys; faceted-filter uses t(option.label))
const typeFilterOptions = [
{
label: `${t('All Types')}${typeCounts?.all ? ` (${typeCounts.all})` : ''}`,
value: 'all',
},
...CHANNEL_TYPE_OPTIONS.map((option) => ({
label: `${t(option.label)}${typeCounts?.[option.value] ? ` (${typeCounts[option.value]})` : ''}`,
value: String(option.value),
})),
]
const groupFilterOptions = [
{ label: t('All Groups'), value: 'all' },
...groupOptions,
]
return (
<>
<div className='space-y-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Filter by name, ID, or key...')}
additionalSearch={
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
className='h-8 w-full sm:w-[150px] lg:w-[200px]'
/>
}
filters={[
{
columnId: 'status',
title: t('Status'),
options: [...CHANNEL_STATUS_OPTIONS],
singleSelect: true,
},
{
columnId: 'type',
title: t('Type'),
options: typeFilterOptions,
singleSelect: true,
},
{
columnId: 'group',
title: t('Group'),
options: groupFilterOptions,
singleSelect: true,
},
]}
/>
{isMobile ? (
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle='No Channels Found'
emptyDescription='No channels available. Create your first channel to get started.'
/>
) : (
<>
<div
className={cn(
'overflow-hidden rounded-md border transition-opacity duration-150',
isFetching && !isLoading && 'pointer-events-none opacity-50'
)}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton table={table} keyPrefix='channel-skeleton' />
) : table.getRowModel().rows.length === 0 ? (
<TableEmpty
colSpan={columns.length}
title={t('No Channels Found')}
description={t(
'No channels available. Create your first channel to get started.'
)}
/>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<DataTableBulkActions table={table} />
</>
)}
</div>
<PageFooterPortal>
<DataTablePagination table={table} />
</PageFooterPortal>
</>
)
}
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import {
handleBatchDelete,
handleBatchDisable,
handleBatchEnable,
handleBatchSetTag,
} from '../lib'
import type { Channel } from '../types'
interface DataTableBulkActionsProps<TData> {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showTagDialog, setShowTagDialog] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [tagValue, setTagValue] = useState('')
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
const id = (row.original as Channel).id
if (typeof id === 'number') {
ids.push(id)
}
return ids
}, [])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleEnableAll = () => {
handleBatchEnable(selectedIds, queryClient, handleClearSelection)
}
const handleDisableAll = () => {
handleBatchDisable(selectedIds, queryClient, handleClearSelection)
}
const handleDeleteAll = () => {
handleBatchDelete(selectedIds, queryClient, () => {
setShowDeleteConfirm(false)
handleClearSelection()
})
}
const handleSetTag = () => {
handleBatchSetTag(selectedIds, tagValue || null, queryClient, () => {
setShowTagDialog(false)
setTagValue('')
handleClearSelection()
})
}
return (
<>
<BulkActionsToolbar table={table} entityName='channel'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleEnableAll}
className='size-8'
aria-label={t('Enable selected channels')}
title={t('Enable selected channels')}
>
<Power />
<span className='sr-only'>{t('Enable selected channels')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('Enable selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleDisableAll}
className='size-8'
aria-label={t('Disable selected channels')}
title={t('Disable selected channels')}
>
<PowerOff />
<span className='sr-only'>{t('Disable selected channels')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('Disable selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => setShowTagDialog(true)}
className='size-8'
aria-label={t('Set tag for selected channels')}
title={t('Set tag for selected channels')}
>
<Tag />
<span className='sr-only'>
{t('Set tag for selected channels')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('Set tag for selected channels')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label={t('Delete selected channels')}
title={t('Delete selected channels')}
>
<Trash2 />
<span className='sr-only'>{t('Delete selected channels')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('Delete selected channels')}</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => {
setShowTagDialog(false)
setTagValue('')
}}
>
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
>
{t('Cancel')}
</Button>
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,246 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Boxes,
Pencil,
TestTube,
DollarSign,
Download,
Copy,
Power,
PowerOff,
Key,
Trash2,
RefreshCw,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { MODEL_FETCHABLE_TYPES } from '../constants'
import {
handleDeleteChannel,
handleToggleChannelStatus,
isChannelEnabled,
isMultiKeyChannel,
} from '../lib'
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
import type { Channel } from '../types'
import { useChannels } from './channels-provider'
interface DataTableRowActionsProps {
row: Row<Channel>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const channel = row.original
const { setOpen, setCurrentRow, upstream } = useChannels()
const queryClient = useQueryClient()
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const isEnabled = isChannelEnabled(channel)
const isMultiKey = isMultiKeyChannel(channel)
const handleEdit = () => {
setCurrentRow(channel)
setOpen('update-channel')
}
const handleTest = () => {
setCurrentRow(channel)
setOpen('test-channel')
}
const handleQueryBalance = () => {
setCurrentRow(channel)
setOpen('balance-query')
}
const handleFetchModels = () => {
setCurrentRow(channel)
setOpen('fetch-models')
}
const handleManageOllamaModels = () => {
setCurrentRow(channel)
setOpen('ollama-models')
}
const handleCopy = () => {
setCurrentRow(channel)
setOpen('copy-channel')
}
const handleManageKeys = () => {
setCurrentRow(channel)
setOpen('multi-key-manage')
}
const handleToggleStatus = () => {
handleToggleChannelStatus(channel.id, channel.status, queryClient)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
<DropdownMenuShortcut>
<TestTube size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
<DropdownMenuShortcut>
<DollarSign size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Fetch Models */}
<DropdownMenuItem onClick={handleFetchModels}>
{t('Fetch Models')}
<DropdownMenuShortcut>
<Download size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Detect Upstream Updates (only for fetchable channel types) */}
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
<DropdownMenuItem
onClick={() => {
const meta = parseUpstreamUpdateMeta(channel.settings)
if (
meta.pendingAddModels.length > 0 ||
meta.pendingRemoveModels.length > 0
) {
upstream.openModal(
channel,
meta.pendingAddModels,
meta.pendingRemoveModels,
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
)
} else {
upstream.detectChannelUpdates(channel)
}
}}
>
{t('Upstream Updates')}
<DropdownMenuShortcut>
<RefreshCw size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Ollama Models (only for Ollama channels) */}
{channel.type === 4 && (
<DropdownMenuItem onClick={handleManageOllamaModels}>
{t('Manage Ollama Models')}
<DropdownMenuShortcut>
<Boxes size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Copy Channel */}
<DropdownMenuItem onClick={handleCopy}>
{t('Copy Channel')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Manage Keys (only for multi-key channels) */}
{isMultiKey && (
<DropdownMenuItem onClick={handleManageKeys}>
{t('Manage Keys')}
<DropdownMenuShortcut>
<Key size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{/* Enable/Disable */}
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Channel')}
desc={`Are you sure you want to delete "${channel.name}"? This action cannot be undone.`}
confirmText='Delete'
destructive
handleConfirm={() => {
handleDeleteChannel(channel.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</DropdownMenu>
)
}
@@ -0,0 +1,96 @@
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal, Power, PowerOff, Pencil, Edit } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { handleEnableTagChannels, handleDisableTagChannels } from '../lib'
import type { Channel } from '../types'
import { useChannels } from './channels-provider'
interface DataTableTagRowActionsProps {
row: Row<Channel & { tag?: string }>
}
export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
const { t } = useTranslation()
const tag = row.original.tag
const { setOpen, setCurrentTag } = useChannels()
const queryClient = useQueryClient()
if (!tag) return null
const handleEnableAll = () => {
handleEnableTagChannels(tag, queryClient)
}
const handleDisableAll = () => {
handleDisableTagChannels(tag, queryClient)
}
const handleBatchEdit = () => {
setCurrentTag(tag)
setOpen('tag-batch-edit')
}
const handleEditTag = () => {
setCurrentTag(tag)
setOpen('edit-tag')
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit Tag */}
<DropdownMenuItem onClick={handleEditTag}>
{t('Edit Tag')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Batch Edit */}
<DropdownMenuItem onClick={handleBatchEdit}>
{t('Batch Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Enable All */}
<DropdownMenuItem onClick={handleEnableAll}>
{t('Enable All')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Disable All */}
<DropdownMenuItem onClick={handleDisableAll}>
{t('Disable All')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
import {
CodexUsageDialog,
type CodexUsageDialogData,
} from './codex-usage-dialog'
type BalanceQueryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BalanceQueryDialog({
open,
onOpenChange,
}: BalanceQueryDialogProps) {
const { t } = useTranslation()
const { currentRow, setCurrentRow } = useChannels()
const queryClient = useQueryClient()
const [isQuerying, setIsQuerying] = useState(false)
const [balance, setBalance] = useState<number | null>(null)
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
null
)
const [codexUsageResponse, setCodexUsageResponse] =
useState<CodexUsageDialogData | null>(null)
const isCodex = currentRow?.type === 57
const handleQueryCodexUsage = async () => {
const row = currentRow
if (!row) return
setIsQuerying(true)
try {
const res = await getCodexUsage(row.id)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch usage'))
}
setCodexUsageResponse(res)
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch usage')
)
} finally {
setIsQuerying(false)
}
}
useEffect(() => {
if (!isCodex) return
if (!open) return
handleQueryCodexUsage()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isCodex])
if (!currentRow) return null
const handleQueryBalance = async () => {
setIsQuerying(true)
try {
const response = await updateChannelBalance(currentRow.id)
if (response.success && response.balance !== undefined) {
const newBalance = response.balance
const now = Math.floor(Date.now() / 1000)
setBalance(newBalance)
setBalanceUpdatedTime(now)
toast.success(t('Balance updated successfully'))
// Update currentRow immediately with new balance and timestamp
setCurrentRow({
...currentRow,
balance: newBalance,
balance_updated_time: now,
})
// Invalidate queries to refresh the table
await queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
} else {
toast.error(response.message || t('Failed to query balance'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to query balance')
)
} finally {
setIsQuerying(false)
}
}
const handleClose = () => {
setBalance(null)
setBalanceUpdatedTime(null)
setCodexUsageResponse(null)
onOpenChange(false)
}
const formatBalance = (bal: number) =>
formatCurrencyFromUSD(bal, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
const formatDate = (timestamp: number) => {
if (!timestamp) return 'Never'
return formatTimestampToDate(timestamp)
}
if (isCodex) {
return (
<CodexUsageDialog
open={open}
onOpenChange={(v) => {
if (!v) handleClose()
}}
channelName={currentRow.name}
channelId={currentRow.id}
response={codexUsageResponse}
onRefresh={handleQueryCodexUsage}
isRefreshing={isQuerying}
/>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,611 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
type ColumnDef,
type RowSelectionState,
type Table as TanStackTable,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Loader2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { StatusBadge } from '@/components/status-badge'
import { formatResponseTime, handleTestChannel } from '../../lib'
import { useChannels } from '../channels-provider'
type ChannelTestDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
type ModelRow = {
model: string
}
type TestStatus = 'idle' | 'testing' | 'success' | 'error'
type TestResult = {
status: TestStatus
responseTime?: number
error?: string
errorCode?: string
}
const endpointTypeOptions: Array<{ value: string; label: string }> = [
{ value: 'auto', label: 'Auto detect (default)' },
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Responses (/v1/responses)' },
{
value: 'openai-response-compact',
label: 'OpenAI Response Compaction (/v1/responses/compact)',
},
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
{
value: 'gemini',
label: 'Gemini (/v1beta/models/{model}:generateContent)',
},
{ value: 'jina-rerank', label: 'Jina Rerank (/v1/rerank)' },
{
value: 'image-generation',
label: 'Image Generation (/v1/images/generations)',
},
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
]
const STREAM_INCOMPATIBLE_ENDPOINTS = new Set([
'embeddings',
'image-generation',
'jina-rerank',
'openai-response-compact',
])
export function ChannelTestDialog({
open,
onOpenChange,
}: ChannelTestDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const [endpointType, setEndpointType] = useState('auto')
const [isStreamTest, setIsStreamTest] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [testingModels, setTestingModels] = useState<Set<string>>(
() => new Set()
)
const [isBatchTesting, setIsBatchTesting] = useState(false)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
})
const resetState = useCallback(() => {
setEndpointType('auto')
setIsStreamTest(false)
setSearchTerm('')
setTestResults({})
setRowSelection({})
setTestingModels(() => new Set())
setIsBatchTesting(false)
setPagination({ pageIndex: 0, pageSize: 10 })
}, [])
useEffect(() => {
if (open && currentRow) {
resetState()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id, resetState])
const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
useEffect(() => {
if (streamDisabled) {
setIsStreamTest(false)
}
}, [streamDisabled])
const modelsValue = currentRow?.models ?? ''
const defaultTestModel = currentRow?.test_model?.trim()
const models = useMemo(() => {
if (!modelsValue) return []
return modelsValue
.split(',')
.map((model) => model.trim())
.filter(Boolean)
}, [modelsValue])
const filteredModels = useMemo(() => {
if (!searchTerm) return models
const keyword = searchTerm.toLowerCase()
return models.filter((model) => model.toLowerCase().includes(keyword))
}, [models, searchTerm])
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}, [searchTerm, modelsValue])
const tableData = useMemo<ModelRow[]>(
() => filteredModels.map((model) => ({ model })),
[filteredModels]
)
const markModelTesting = useCallback((key: string, isTesting: boolean) => {
setTestingModels((prev) => {
const next = new Set(prev)
if (isTesting) {
next.add(key)
} else {
next.delete(key)
}
return next
})
}, [])
const updateTestResult = useCallback((key: string, result: TestResult) => {
setTestResults((prev) => ({
...prev,
[key]: result,
}))
}, [])
const testSingleModel = useCallback(
async (model: string) => {
if (!currentRow) return
markModelTesting(model, true)
updateTestResult(model, { status: 'testing' })
try {
await handleTestChannel(
currentRow.id,
{
testModel: model,
endpointType: endpointType === 'auto' ? undefined : endpointType,
stream: isStreamTest || undefined,
},
(success, responseTime, error, errorCode) => {
updateTestResult(model, {
status: success ? 'success' : 'error',
responseTime,
error,
errorCode,
})
}
)
} catch (error: unknown) {
updateTestResult(model, {
status: 'error',
error: error instanceof Error ? error.message : 'Test failed',
})
} finally {
markModelTesting(model, false)
}
},
[currentRow, endpointType, isStreamTest, markModelTesting, updateTestResult]
)
const handleBatchTest = useCallback(
async (modelsToTest: string[]) => {
if (!modelsToTest.length) return
setIsBatchTesting(true)
try {
await Promise.allSettled(
modelsToTest.map((modelName) => testSingleModel(modelName))
)
} finally {
setIsBatchTesting(false)
setRowSelection({})
}
},
[testSingleModel]
)
const handleClose = () => {
resetState()
onOpenChange(false)
}
const isAnyTesting = testingModels.size > 0 || isBatchTesting
const columns = useMemo<ColumnDef<ModelRow>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label='Select all models'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={`Select model ${row.original.model}`}
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: 'model',
header: 'Model',
cell: ({ row }) => {
const model = row.original.model
const isDefault = defaultTestModel === model
return (
<div className='flex items-center gap-2'>
<span className='font-medium'>{model}</span>
{isDefault && (
<StatusBadge
label='Default'
variant='info'
size='sm'
copyable={false}
/>
)}
</div>
)
},
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => {
const model = row.original.model
const result = testResults[model]
if (!result || result.status === 'idle') {
return (
<StatusBadge
label={t('Not tested')}
variant='neutral'
copyable={false}
/>
)
}
if (result.status === 'testing') {
return (
<div className='text-muted-foreground flex items-center gap-2 text-sm'>
<Loader2 className='h-4 w-4 animate-spin' />
Testing...
</div>
)
}
if (result.status === 'success') {
return (
<div className='flex flex-col gap-1 text-xs'>
<StatusBadge
label='Success'
variant='success'
copyable={false}
/>
{typeof result.responseTime === 'number' && (
<span className='text-muted-foreground'>
{formatResponseTime(result.responseTime, t)}
</span>
)}
</div>
)
}
return (
<div className='flex flex-col gap-1 text-xs'>
<StatusBadge label='Failed' variant='danger' copyable={false} />
{result.error && (
<span className='text-muted-foreground break-all'>
{result.error}
</span>
)}
{result.errorCode === 'model_price_error' && (
<Button
variant='outline'
size='sm'
className='w-fit'
onClick={() =>
window.open('/console/setting?tab=ratio', '_blank')
}
>
<Settings className='mr-1 h-3 w-3' />
{t('Go to Settings')}
</Button>
)}
</div>
)
},
enableSorting: false,
size: 220,
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const model = row.original.model
const isTestingModel = testingModels.has(model)
return (
<Button
variant='outline'
size='sm'
onClick={() => testSingleModel(model)}
disabled={isTestingModel || isBatchTesting}
>
{isTestingModel && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
Test
</Button>
)
},
enableSorting: false,
size: 120,
},
],
[
defaultTestModel,
isBatchTesting,
t,
testResults,
testingModels,
testSingleModel,
]
)
const table = useReactTable({
data: tableData,
columns,
state: {
rowSelection,
pagination,
},
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
})
if (!currentRow) {
return null
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select value={endpointType} onValueChange={setEndpointType}>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div className='overflow-hidden rounded-md border' role='region'>
<div className='max-h-[360px] overflow-y-auto'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function TestModelsBulkActions({
table,
disabled,
onTestSelected,
}: {
table: TanStackTable<ModelRow>
disabled?: boolean
onTestSelected: (models: string[]) => void
}) {
const { t } = useTranslation()
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedModels = selectedRows.map((row) => row.original.model)
const buttonLabel =
selectedModels.length > 0
? `Test ${selectedModels.length} selected`
: 'Test selected models'
return (
<BulkActionsToolbar table={table} entityName='model'>
<Tooltip>
<TooltipTrigger asChild>
<Button
size='sm'
onClick={() => onTestSelected(selectedModels)}
disabled={disabled || selectedModels.length === 0}
>
{disabled ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Testing...')}
</>
) : (
buttonLabel
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('Run tests for the selected models')}</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
)
}
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useState } from 'react'
import { ExternalLink, Copy, Check, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onKeyGenerated: (key: string) => void
}
export function CodexOAuthDialog({
open,
onOpenChange,
onKeyGenerated,
}: CodexOAuthDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const [state, setState] = useState({
authorizeUrl: '',
callbackUrl: '',
isStarting: false,
isCompleting: false,
})
useEffect(() => {
if (!open) {
setState({
authorizeUrl: '',
callbackUrl: '',
isStarting: false,
isCompleting: false,
})
}
}, [open])
const canCopyAuthorizeUrl = Boolean(state.authorizeUrl && !state.isStarting)
const canComplete = useMemo(
() => Boolean(state.callbackUrl.trim()) && !state.isCompleting,
[state.callbackUrl, state.isCompleting]
)
const handleStart = async () => {
setState((prev) => ({ ...prev, isStarting: true }))
try {
const res = await startCodexOAuth()
if (!res.success) {
throw new Error(res.message || 'Failed to start OAuth')
}
const url = res.data?.authorize_url || ''
if (!url) {
throw new Error('Missing authorize_url in response')
}
setState((prev) => ({ ...prev, authorizeUrl: url }))
try {
window.open(url, '_blank', 'noopener,noreferrer')
toast.success(t('Opened authorization page'))
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Failed to open authorization page:', error)
toast.warning(t('Please manually copy and open the authorization link'))
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('OAuth start failed')
)
} finally {
setState((prev) => ({ ...prev, isStarting: false }))
}
}
const handleComplete = async () => {
if (!state.callbackUrl.trim()) return
setState((prev) => ({ ...prev, isCompleting: true }))
try {
const res = await completeCodexOAuth(state.callbackUrl.trim())
if (!res.success) {
throw new Error(res.message || 'OAuth failed')
}
const rawKey = res.data?.key || ''
if (!rawKey) {
throw new Error('Missing key in response')
}
onKeyGenerated(tryPrettyJson(rawKey))
toast.success(t('Credential generated'))
onOpenChange(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : t('OAuth failed'))
} finally {
setState((prev) => ({ ...prev, isCompleting: false }))
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={state.isStarting || state.isCompleting}
>
{t('Cancel')}
</Button>
<Button onClick={handleComplete} disabled={!canComplete}>
{state.isCompleting && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,581 @@
import { useMemo, useState } from 'react'
import {
Copy,
Check,
RefreshCw,
ChevronDown,
ChevronUp,
User,
Mail,
Hash,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
used_percent?: number
reset_at?: number
reset_after_seconds?: number
limit_window_seconds?: number
}
type CodexRateLimit = {
plan_type?: string
allowed?: boolean
limit_reached?: boolean
primary_window?: CodexRateLimitWindow
secondary_window?: CodexRateLimitWindow
}
type CodexAdditionalRateLimit = {
limit_name?: string
metered_feature?: string
rate_limit?: CodexRateLimit
primary_window?: CodexRateLimitWindow
secondary_window?: CodexRateLimitWindow
plan_type?: string
}
type CodexUsagePayload = {
plan_type?: string
user_id?: string
email?: string
account_id?: string
rate_limit?: CodexRateLimit
additional_rate_limits?: CodexAdditionalRateLimit[]
}
export type CodexUsageDialogData = {
success: boolean
message?: string
upstream_status?: number
data?: Record<string, unknown>
}
type CodexUsageDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
channelName?: string
channelId?: number
response: CodexUsageDialogData | null
onRefresh?: () => void
isRefreshing?: boolean
}
function clampPercent(value: unknown): number {
const v = Number(value)
return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 0
}
function formatUnixSeconds(unixSeconds: unknown): string {
const v = Number(unixSeconds)
if (!Number.isFinite(v) || v <= 0) return '-'
try {
return dayjs(v * 1000).format('YYYY-MM-DD HH:mm:ss')
} catch {
return String(unixSeconds)
}
}
function formatDurationSeconds(
seconds: unknown,
t: (key: string) => string
): string {
const s = Number(seconds)
if (!Number.isFinite(s) || s <= 0) return '-'
const total = Math.floor(s)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) return `${hours}${t('h')} ${minutes}${t('m')}`
if (minutes > 0) return `${minutes}${t('m')} ${secs}${t('s')}`
return `${secs}${t('s')}`
}
function normalizePlanType(value: unknown): string {
if (value == null) return ''
return String(value).trim().toLowerCase()
}
function classifyWindowByDuration(
windowData?: CodexRateLimitWindow | null
): 'weekly' | 'fiveHour' | null {
const seconds = Number(windowData?.limit_window_seconds)
if (!Number.isFinite(seconds) || seconds <= 0) return null
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour'
}
type RateLimitSource = {
plan_type?: string
rate_limit?: CodexRateLimit
}
function resolveRateLimitWindows(data: RateLimitSource | null): {
fiveHourWindow: CodexRateLimitWindow | null
weeklyWindow: CodexRateLimitWindow | null
} {
const rateLimit = data?.rate_limit ?? {}
const primary = rateLimit?.primary_window ?? null
const secondary = rateLimit?.secondary_window ?? null
const windows = [primary, secondary].filter(Boolean) as CodexRateLimitWindow[]
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type)
let fiveHourWindow: CodexRateLimitWindow | null = null
let weeklyWindow: CodexRateLimitWindow | null = null
for (const w of windows) {
const bucket = classifyWindowByDuration(w)
if (bucket === 'fiveHour' && !fiveHourWindow) {
fiveHourWindow = w
continue
}
if (bucket === 'weekly' && !weeklyWindow) {
weeklyWindow = w
}
}
if (planType === 'free') {
if (!weeklyWindow) weeklyWindow = primary ?? secondary ?? null
return { fiveHourWindow: null, weeklyWindow }
}
if (!fiveHourWindow && !weeklyWindow) {
return { fiveHourWindow: primary, weeklyWindow: secondary }
}
if (!fiveHourWindow) {
fiveHourWindow = windows.find((w) => w !== weeklyWindow) ?? null
}
if (!weeklyWindow) {
weeklyWindow = windows.find((w) => w !== fiveHourWindow) ?? null
}
return { fiveHourWindow, weeklyWindow }
}
const PLAN_TYPE_BADGE: Record<
string,
{ label: string; variant: StatusBadgeProps['variant'] }
> = {
enterprise: { label: 'Enterprise', variant: 'success' },
team: { label: 'Team', variant: 'info' },
pro: { label: 'Pro', variant: 'blue' },
plus: { label: 'Plus', variant: 'purple' },
free: { label: 'Free', variant: 'warning' },
}
function getAccountTypeBadge(
value: unknown,
t: (key: string) => string
): { label: string; variant: StatusBadgeProps['variant'] } {
const normalized = normalizePlanType(value)
return (
PLAN_TYPE_BADGE[normalized] ?? {
label: String(value || '') || t('Unknown'),
variant: 'neutral' as const,
}
)
}
function windowLabel(windowData?: CodexRateLimitWindow | null) {
const percent = clampPercent(windowData?.used_percent)
const variant: StatusBadgeProps['variant'] =
percent >= 95 ? 'danger' : percent >= 80 ? 'warning' : 'info'
return { percent, variant }
}
type RateLimitWindowProps = {
title: string
window?: CodexRateLimitWindow | null
}
function RateLimitWindow(props: RateLimitWindowProps) {
const { t } = useTranslation()
const hasData =
!!props.window &&
typeof props.window === 'object' &&
Object.keys(props.window).length > 0
const { percent, variant } = windowLabel(props.window)
return (
<div className='rounded-lg border p-4'>
<div className='flex items-center justify-between gap-2'>
<div className='text-sm font-medium'>{props.title}</div>
<StatusBadge label={`${percent}%`} variant={variant} copyable={false} />
</div>
<div className='mt-3'>
<Progress
value={percent}
aria-label={`${props.title} usage: ${percent}%`}
/>
</div>
{hasData ? (
<div className='text-muted-foreground mt-2 space-y-1 text-xs'>
<div>
{t('Reset at:')} {formatUnixSeconds(props.window?.reset_at)}
</div>
<div>
{t('Resets in:')}{' '}
{formatDurationSeconds(props.window?.reset_after_seconds, t)}
</div>
<div>
{t('Window:')}{' '}
{formatDurationSeconds(props.window?.limit_window_seconds, t)}
</div>
</div>
) : (
<div className='text-muted-foreground mt-2 text-xs'>-</div>
)}
</div>
)
}
type RateLimitGroupSectionProps = {
title: string
description?: string
source: RateLimitSource | null
meteredFeature?: string
}
function RateLimitGroupSection(props: RateLimitGroupSectionProps) {
const { t } = useTranslation()
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(props.source)
return (
<section className='space-y-3'>
<div className='space-y-1'>
<div className='text-sm font-semibold'>{props.title}</div>
{(props.description || props.meteredFeature) && (
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs'>
{props.description && <span>{props.description}</span>}
{props.meteredFeature && (
<span className='bg-muted/60 inline-flex max-w-full items-center gap-2 rounded-full px-2 py-0.5'>
<span className='text-[11px]'>metered_feature</span>
<span className='min-w-0 font-mono text-xs break-all'>
{props.meteredFeature}
</span>
</span>
)}
</div>
)}
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<RateLimitWindow title={t('5-Hour Window')} window={fiveHourWindow} />
<RateLimitWindow title={t('Weekly Window')} window={weeklyWindow} />
</div>
</section>
)
}
function CopyableField(props: {
icon: React.ReactNode
label: string
value?: string | null
mono?: boolean
}) {
const { copyToClipboard, copiedText } = useCopyToClipboard({ notify: false })
const text = props.value?.trim() || ''
const hasCopied = copiedText === text
return (
<div className='flex items-center justify-between gap-2 py-1'>
<div className='flex min-w-0 items-center gap-2'>
<span className='text-muted-foreground flex-shrink-0'>
{props.icon}
</span>
<span className='text-muted-foreground flex-shrink-0 text-xs'>
{props.label}
</span>
<span
className={`min-w-0 truncate text-xs ${props.mono ? 'font-mono' : ''}`}
>
{text || '-'}
</span>
</div>
{text && (
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 w-6 flex-shrink-0 p-0'
onClick={() => copyToClipboard(text)}
>
{hasCopied ? (
<Check className='h-3 w-3 text-green-600' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
)}
</div>
)
}
export function CodexUsageDialog({
open,
onOpenChange,
channelName,
channelId,
response,
onRefresh,
isRefreshing,
}: CodexUsageDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
const [showRawJson, setShowRawJson] = useState(false)
const payload: CodexUsagePayload | null = useMemo(() => {
const raw = response?.data
if (!raw || typeof raw !== 'object') return null
return raw as CodexUsagePayload
}, [response?.data])
const rateLimit = payload?.rate_limit
const accountType = payload?.plan_type ?? rateLimit?.plan_type
const accountBadge = getAccountTypeBadge(accountType, t)
const additionalRateLimits = (payload?.additional_rate_limits ?? []).filter(
(item) => item && Object.keys(item).length > 0
)
const statusBadge = (() => {
if (!rateLimit || Object.keys(rateLimit).length === 0) {
return (
<StatusBadge label={t('Pending')} variant='neutral' copyable={false} />
)
}
if (rateLimit.allowed && !rateLimit.limit_reached) {
return (
<StatusBadge
label={t('Available')}
variant='success'
copyable={false}
/>
)
}
return (
<StatusBadge label={t('Limited')} variant='danger' copyable={false} />
)
})()
const errorMessage =
response?.success === false
? response?.message?.trim() || t('Failed to fetch usage')
: ''
const rawJsonText = useMemo(() => {
if (!response) return ''
try {
return JSON.stringify(
{
success: response.success,
message: response.message,
upstream_status: response.upstream_status,
data: response.data,
},
null,
2
)
} catch {
return String(response?.data ?? '')
}
}, [response])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,113 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
type CopyChannelDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CopyChannelDialog({
open,
onOpenChange,
}: CopyChannelDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const [suffix, setSuffix] = useState('_copy')
const [resetBalance, setResetBalance] = useState(true)
const [isCopying, setIsCopying] = useState(false)
if (!currentRow) return null
const handleCopy = async () => {
setIsCopying(true)
await handleCopyChannel(
currentRow.id,
{
suffix,
reset_balance: resetBalance,
},
queryClient,
() => {
onOpenChange(false)
setSuffix('_copy')
setResetBalance(true)
}
)
setIsCopying(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCopying}
>
{t('Cancel')}
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,420 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { StatusBadge } from '@/components/status-badge'
import {
editTagChannels,
getTagModels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
type EditTagDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
// Form state
const [newTag, setNewTag] = useState('')
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [customModel, setCustomModel] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
// Fetch tag models
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
queryKey: ['tag-models', currentTag],
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
enabled: open && !!currentTag,
})
// Fetch all available models
const { data: allModelsData } = useQuery({
queryKey: ['all-models'],
queryFn: getAllModels,
enabled: open,
})
// Fetch groups
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
enabled: open,
})
const availableModels =
allModelsData?.data?.map((m) => m.id).filter(Boolean) || []
const availableGroups = groupsData?.data || []
// Initialize form when tag changes
useEffect(() => {
if (open && currentTag) {
setNewTag(currentTag)
setModelMapping('')
setSelectedGroups([])
setCustomModel('')
// Load tag models
if (tagModelsData?.data) {
const models = tagModelsData.data.split(',').filter(Boolean)
setSelectedModels(models)
} else {
setSelectedModels([])
}
}
}, [open, currentTag, tagModelsData])
const handleAddCustomModel = () => {
if (!customModel.trim()) return
const modelsToAdd = customModel
.split(',')
.map((m) => m.trim())
.filter(Boolean)
.filter((m) => !selectedModels.includes(m))
if (modelsToAdd.length > 0) {
setSelectedModels([...selectedModels, ...modelsToAdd])
toast.success(
t('Added {{count}} model(s)', { count: modelsToAdd.length })
)
setCustomModel('')
} else {
toast.info(t('No new models to add'))
}
}
const handleRemoveModel = (model: string) => {
setSelectedModels(selectedModels.filter((m) => m !== model))
}
const handleToggleGroup = (group: string) => {
setSelectedGroups((prev) =>
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
)
}
const validateForm = () => {
// Validate model mapping if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch {
toast.error(t('Model mapping must be valid JSON'))
return false
}
}
return true
}
const handleSubmit = async () => {
if (!currentTag) return
if (!validateForm()) return
// Check if anything changed
const hasChanges =
newTag !== currentTag ||
modelMapping.trim() ||
selectedModels.length > 0 ||
selectedGroups.length > 0
if (!hasChanges) {
toast.warning(t('No changes to save'))
return
}
setIsSubmitting(true)
try {
const params: Record<string, string | null> = { tag: currentTag }
if (newTag && newTag !== currentTag) {
params.new_tag = newTag || null
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (selectedModels.length > 0) {
params.models = selectedModels.join(',')
}
if (selectedGroups.length > 0) {
params.groups = selectedGroups.join(',')
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select
onValueChange={(value) => {
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<StatusBadge
key={group}
variant={
selectedGroups.includes(group) ? 'info' : 'neutral'
}
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleToggleGroup(group)}
>
{group}
</StatusBadge>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,473 @@
import { useState, useEffect, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
categorizeModelsWithRedirect,
normalizeModelName,
parseModelsString,
} from '../../lib'
import { useChannels } from '../channels-provider'
function normalizeModelNameList(models: readonly string[]): string[] {
return Array.from(
new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))
)
}
type FetchModelsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onModelsSelected?: (models: string[]) => void
redirectModels?: string[]
redirectSourceModels?: string[]
}
export function FetchModelsDialog({
open,
onOpenChange,
onModelsSelected,
redirectModels = [],
redirectSourceModels = [],
}: FetchModelsDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
const [isFetching, setIsFetching] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [selectedModels, setSelectedModels] = useState<string[]>([])
const [searchKeyword, setSearchKeyword] = useState('')
// Parse existing models
const existingModels = useMemo(
() => parseModelsString(currentRow?.models || ''),
[currentRow?.models]
)
// Categorize models with redirect models
const modelCategories = useMemo(
() => categorizeModelsWithRedirect(existingModels, redirectModels),
[existingModels, redirectModels]
)
const { classificationSet, redirectOnlySet } = modelCategories
const fetchedModelSet = useMemo(
() => new Set(normalizeModelNameList(fetchedModels)),
[fetchedModels]
)
// Source keys in model_mapping are aliases, not real upstream IDs, so we
// must skip them when computing "removed upstream" entries to avoid false
// positives.
const redirectSourceKeysSet = useMemo(
() => new Set(normalizeModelNameList(redirectSourceModels)),
[redirectSourceModels]
)
const removedModels = useMemo(() => {
const kw = searchKeyword.toLowerCase().trim()
return normalizeModelNameList(selectedModels).filter((model) => {
if (fetchedModelSet.has(model)) return false
if (redirectSourceKeysSet.has(model)) return false
if (!kw) return true
return model.toLowerCase().includes(kw)
})
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
useEffect(() => {
if (open && currentRow) {
handleFetchModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
const handleFetchModels = async () => {
if (!currentRow) return
setIsFetching(true)
try {
const response = await fetchUpstreamModels(currentRow.id)
if (response.success) {
const list = Array.isArray(response.data) ? response.data : []
setFetchedModels(list)
setSelectedModels(existingModels)
toast.success(t('Fetched {{count}} models', { count: list.length }))
} else {
toast.error(response.message || t('Failed to fetch models'))
setFetchedModels([])
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to fetch models')
)
setFetchedModels([])
} finally {
setIsFetching(false)
}
}
const handleSave = async () => {
if (!currentRow) return
// If onModelsSelected callback is provided, use it (form filling mode)
if (onModelsSelected) {
onModelsSelected(selectedModels)
toast.success(t('Models filled to form'))
onOpenChange(false)
return
}
// Otherwise, directly save to API (standalone mode)
setIsSaving(true)
try {
const modelsString = selectedModels.join(',')
const response = await updateChannel(currentRow.id, {
models: modelsString,
})
if (response.success) {
toast.success(t('Models updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onOpenChange(false)
} else {
toast.error(response.message || t('Failed to update models'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update models')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setFetchedModels([])
setSelectedModels([])
setSearchKeyword('')
onOpenChange(false)
}
// Categorize models by common prefixes
const categorizeModels = (models: string[]) => {
const categories: Record<string, string[]> = {}
models.forEach((model) => {
let category = 'Other'
// Determine category based on model name
if (
model.toLowerCase().includes('gpt') ||
model.toLowerCase().includes('o1') ||
model.toLowerCase().includes('o3')
) {
category = 'OpenAI'
} else if (model.toLowerCase().includes('claude')) {
category = 'Anthropic'
} else if (model.toLowerCase().includes('gemini')) {
category = 'Gemini'
} else if (model.toLowerCase().includes('qwen')) {
category = 'Qwen'
} else if (model.toLowerCase().includes('deepseek')) {
category = 'DeepSeek'
} else if (model.toLowerCase().includes('glm')) {
category = 'Zhipu'
} else if (model.toLowerCase().includes('llama')) {
category = 'Meta'
} else if (model.toLowerCase().includes('mistral')) {
category = 'Mistral'
}
if (!categories[category]) {
categories[category] = []
}
categories[category].push(model)
})
return categories
}
// Filter models by search
const filteredModels = useMemo(() => {
if (!searchKeyword) return fetchedModels
return fetchedModels.filter((model) =>
model.toLowerCase().includes(searchKeyword.toLowerCase())
)
}, [fetchedModels, searchKeyword])
// Helper to check if a model is considered "existing" (in selected or redirect)
const isExistingModel = (model: string) =>
classificationSet.has(normalizeModelName(model))
// Separate new and existing models
const newModels = filteredModels.filter((m) => !isExistingModel(m))
const existingFilteredModels = filteredModels.filter((m) =>
isExistingModel(m)
)
const newModelsByCategory = categorizeModels(newModels)
const existingModelsByCategory = categorizeModels(existingFilteredModels)
// 厂商分类按 a-z 排序,Other 放最后,便于查找
const getSortedCategoryEntries = (
categories: Record<string, string[]>
): [string, string[]][] =>
Object.entries(categories).sort(([a], [b]) => {
if (a === 'Other') return 1
if (b === 'Other') return -1
return a.localeCompare(b, undefined, { sensitivity: 'base' })
})
const toggleModel = (model: string) => {
setSelectedModels((prev) =>
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
)
}
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
setSelectedModels((prev) => {
if (isChecked) {
const newSelected = [...prev]
categoryModels.forEach((model) => {
if (!newSelected.includes(model)) {
newSelected.push(model)
}
})
return newSelected
} else {
return prev.filter((m) => !categoryModels.includes(m))
}
})
}
const isCategorySelected = (categoryModels: string[]) => {
return categoryModels.every((m) => selectedModels.includes(m))
}
const renderModelCategory = (
categoryName: string,
categoryModels: string[]
) => {
const allSelected = isCategorySelected(categoryModels)
return (
<Collapsible key={categoryName} defaultOpen>
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
<div className='flex items-center gap-2'>
<ChevronDown className='h-4 w-4' />
<span className='font-medium'>
{categoryName} ({categoryModels.length})
</span>
</div>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
/ {categoryModels.length} selected
</span>
<Checkbox
checked={allSelected}
onCheckedChange={(checked) =>
toggleCategory(categoryModels, !!checked)
}
onClick={(e) => e.stopPropagation()}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className='px-4 py-2'>
<div className='grid grid-cols-2 gap-2'>
{categoryModels.map((model) => (
<div key={model} className='flex items-center space-x-2'>
<Checkbox
id={model}
checked={selectedModels.includes(model)}
onCheckedChange={() => toggleModel(model)}
/>
<Label
htmlFor={model}
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
>
<span>{model}</span>
{redirectOnlySet.has(normalizeModelName(model)) && (
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-amber-500' />
</TooltipTrigger>
<TooltipContent>
{t('From model redirect, not yet added to models list')}
</TooltipContent>
</Tooltip>
)}
</Label>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{t('Fetch available models for:')}{' '}
<strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!currentRow ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${currentRow?.id}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,84 @@
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
type MissingModelsConfirmationDialogProps = {
open: boolean
missingModels: string[]
onConfirm: (action: MissingModelsAction) => void
onOpenChange?: (open: boolean) => void
}
/**
* Confirmation dialog shown when models in model_mapping are missing from the models list
* Provides three options:
* 1. Cancel - Go back to edit
* 2. Submit - Submit anyway without adding missing models
* 3. Add - Automatically add missing models and submit
*/
export function MissingModelsConfirmationDialog({
open,
missingModels,
onConfirm,
onOpenChange,
}: MissingModelsConfirmationDialogProps) {
const { t } = useTranslation()
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
onConfirm('cancel')
}
onOpenChange?.(newOpen)
}
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Models not in list, may fail to invoke')}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className='space-y-3 text-sm'>
<div>
{t(
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
)}
</div>
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
{missingModels.join(', ')}
</div>
<div>
{t(
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
)}
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex-col gap-2 sm:flex-row'>
<AlertDialogCancel onClick={() => onConfirm('cancel')}>
{t('Go back and edit')}
</AlertDialogCancel>
<Button variant='secondary' onClick={() => onConfirm('submit')}>
{t('Submit directly')}
</Button>
<AlertDialogAction onClick={() => onConfirm('add')}>
{t('Add and submit')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,423 @@
import { useState, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
enableMultiKey,
disableMultiKey,
deleteMultiKey,
enableAllMultiKeys,
disableAllMultiKeys,
deleteDisabledMultiKeys,
} from '../../api'
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
import {
channelsQueryKeys,
formatTimestamp,
getMultiKeyStatusConfig,
getMultiKeyConfirmMessage,
isDestructiveAction,
} from '../../lib'
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
import { useChannels } from '../channels-provider'
import { StatisticsCard } from './multi-key-statistics-card'
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
type MultiKeyManageDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function MultiKeyManageDialog({
open,
onOpenChange,
}: MultiKeyManageDialogProps) {
const { t } = useTranslation()
const { currentRow } = useChannels()
const queryClient = useQueryClient()
// Data state
const [isLoading, setIsLoading] = useState(false)
const [keys, setKeys] = useState<KeyStatus[]>([])
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [enabledCount, setEnabledCount] = useState(0)
const [manualDisabledCount, setManualDisabledCount] = useState(0)
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
// UI state
const [statusFilter, setStatusFilter] = useState<number | null>(null)
const [confirmAction, setConfirmAction] =
useState<MultiKeyConfirmAction | null>(null)
const [isPerformingAction, setIsPerformingAction] = useState(false)
// Reset and load data when dialog opens
useEffect(() => {
if (open && currentRow) {
setCurrentPage(1)
setStatusFilter(null)
loadKeyStatus(1, pageSize, null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRow?.id])
const loadKeyStatus = async (
page: number = currentPage,
size: number = pageSize,
status: number | null = statusFilter
) => {
if (!currentRow) return
setIsLoading(true)
try {
const response = await getMultiKeyStatus(
currentRow.id,
page,
size,
status === null ? undefined : status
)
if (response.success && response.data) {
setKeys(response.data.keys || [])
setTotal(response.data.total || 0)
setCurrentPage(response.data.page || 1)
setPageSize(response.data.page_size || 10)
setTotalPages(response.data.total_pages || 0)
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
setAutoDisabledCount(response.data.auto_disabled_count || 0)
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : 'Failed to load key status'
)
} finally {
setIsLoading(false)
}
}
const handleStatusFilterChange = (value: string) => {
const newFilter = value === 'all' ? null : parseInt(value)
setStatusFilter(newFilter)
setCurrentPage(1)
loadKeyStatus(1, pageSize, newFilter)
}
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage)
loadKeyStatus(newPage, pageSize)
}
const performAction = async () => {
if (!confirmAction || !currentRow) return
setIsPerformingAction(true)
try {
const { type, keyIndex } = confirmAction
let response
// Execute the appropriate action
if (type === 'enable' && keyIndex !== undefined) {
response = await enableMultiKey(currentRow.id, keyIndex)
} else if (type === 'disable' && keyIndex !== undefined) {
response = await disableMultiKey(currentRow.id, keyIndex)
} else if (type === 'delete' && keyIndex !== undefined) {
response = await deleteMultiKey(currentRow.id, keyIndex)
} else if (type === 'enable-all') {
response = await enableAllMultiKeys(currentRow.id)
} else if (type === 'disable-all') {
response = await disableAllMultiKeys(currentRow.id)
} else if (type === 'delete-disabled') {
response = await deleteDisabledMultiKeys(currentRow.id)
}
if (response?.success) {
toast.success(response.message || 'Operation successful')
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
// Reload data - reset to page 1 for bulk actions
const isBulkAction = type.includes('all') || type === 'delete-disabled'
if (isBulkAction) {
setCurrentPage(1)
loadKeyStatus(1, pageSize)
} else {
loadKeyStatus(currentPage, pageSize)
}
} else {
toast.error(response?.message || 'Operation failed')
}
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : 'Operation failed')
} finally {
setIsPerformingAction(false)
setConfirmAction(null)
}
}
const renderStatusBadge = (status: number) => {
const config = getMultiKeyStatusConfig(status)
return (
<StatusBadge
label={t(config.label)}
variant={config.variant}
showDot
copyable={false}
/>
)
}
const formatKeyTimestamp = (timestamp?: number) => {
if (!timestamp) return '-'
return formatTimestamp(timestamp)
}
if (!currentRow) return null
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={handleStatusFilterChange}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Confirmation Dialog */}
<ConfirmDialog
open={confirmAction !== null}
onOpenChange={(open) => !open && setConfirmAction(null)}
title={t('Confirm Action')}
desc={t(getMultiKeyConfirmMessage(confirmAction))}
destructive={isDestructiveAction(confirmAction)}
isLoading={isPerformingAction}
handleConfirm={performAction}
/>
</>
)
}
@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
type StatisticsCardProps = {
label: string
count: number
total: number
}
export function StatisticsCard({ label, count, total }: StatisticsCardProps) {
const { t } = useTranslation()
return (
<div className='rounded-md border p-3'>
<div className='text-muted-foreground mb-1 text-xs font-medium'>
{label}
</div>
<div className='flex items-baseline gap-2'>
<span className='text-foreground text-2xl font-semibold'>{count}</span>
<span className='text-muted-foreground text-sm'>
{t('of')} {total}
</span>
</div>
</div>
)
}
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { MultiKeyConfirmAction } from '../../types'
type MultiKeyTableRowActionsProps = {
keyIndex: number
status: number
onAction: (action: MultiKeyConfirmAction) => void
}
export function MultiKeyTableRowActions({
keyIndex,
status,
onAction,
}: MultiKeyTableRowActionsProps) {
const { t } = useTranslation()
const isEnabled = status === 1
return (
<div className='flex justify-end gap-2'>
{isEnabled ? (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'disable', keyIndex })}
>
{t('Disable')}
</Button>
) : (
<Button
variant='outline'
size='sm'
onClick={() => onAction({ type: 'enable', keyIndex })}
>
{t('Enable')}
</Button>
)}
<Button
variant='destructive'
size='sm'
onClick={() => onAction({ type: 'delete', keyIndex })}
>
{t('Delete')}
</Button>
</div>
)
}
@@ -0,0 +1,588 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getCommonHeaders } from '@/lib/api'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
fetchUpstreamModels,
updateChannel,
} from '../../api'
import { channelsQueryKeys, parseModelsString } from '../../lib'
import {
formatBytes,
normalizeOllamaModels,
resolveOllamaBaseUrl,
type OllamaModel,
type PullProgress,
} from '../../lib/ollama-utils'
import { useChannels } from '../channels-provider'
const CHANNEL_TYPE_OLLAMA = 4
export function OllamaModelsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { currentRow } = useChannels()
const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
const channelId = currentRow?.id
const [isFetching, setIsFetching] = useState(false)
const [models, setModels] = useState<OllamaModel[]>([])
const [selected, setSelected] = useState<string[]>([])
const [search, setSearch] = useState('')
const [pullName, setPullName] = useState('')
const [isPulling, setIsPulling] = useState(false)
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
const pullAbortRef = useRef<AbortController | null>(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const filteredModels = useMemo(() => {
if (!search.trim()) return models
const keyword = search.trim().toLowerCase()
return models.filter((m) => m.id.toLowerCase().includes(keyword))
}, [models, search])
const existingModels = useMemo(
() => parseModelsString(currentRow?.models ?? ''),
[currentRow?.models]
)
useEffect(() => {
if (!open) {
setModels([])
setSelected([])
setSearch('')
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current?.abort()
pullAbortRef.current = null
return
}
if (open && isOllamaChannel && channelId) {
void fetchOllamaModels()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isOllamaChannel, channelId])
const fetchOllamaModels = useCallback(async () => {
if (!channelId) return
setIsFetching(true)
try {
let normalized: OllamaModel[] = []
let lastErr = ''
// 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
if (isOllamaChannel && baseUrl) {
try {
const payloadLive = await fetchModelsFromEndpoint({
base_url: baseUrl,
type: CHANNEL_TYPE_OLLAMA,
key: typeof currentRow?.key === 'string' ? currentRow.key : '',
})
if (payloadLive?.success) {
normalized = normalizeOllamaModels(payloadLive.data)
} else if (payloadLive?.message) {
lastErr = String(payloadLive.message)
}
} catch (err: unknown) {
lastErr = err instanceof Error ? err.message : ''
}
}
// 2) Fallback to server-side fetch by channelId
if (!normalized.length) {
const payload = await fetchUpstreamModels(Number(channelId))
if (payload?.success) {
normalized = normalizeOllamaModels(payload.data)
lastErr = ''
} else {
lastErr = String(payload?.message || '')
}
}
if (!normalized.length && lastErr) {
toast.error(lastErr || t('Failed to fetch models'))
}
setModels(normalized)
setSelected((prev) => {
if (!prev.length) return normalized.map((m) => m.id)
const stillAvailable = prev.filter((id) =>
normalized.some((m) => m.id === id)
)
return stillAvailable.length
? stillAvailable
: normalized.map((m) => m.id)
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to fetch models'))
setModels([])
} finally {
setIsFetching(false)
}
}, [channelId, currentRow, isOllamaChannel, t])
const toggleSelected = (modelId: string, checked: boolean) => {
setSelected((prev) => {
if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
return prev.filter((id) => id !== modelId)
})
}
const selectAllFiltered = () => {
setSelected((prev) => {
const next = new Set(prev)
filteredModels.forEach((m) => next.add(m.id))
return Array.from(next)
})
}
const clearSelection = () => setSelected([])
const applySelection = async (mode: 'append' | 'replace') => {
if (!currentRow) return
if (!selected.length) {
toast.info(t('No models selected'))
return
}
const next =
mode === 'replace'
? Array.from(new Set(selected))
: Array.from(new Set([...existingModels, ...selected]))
const res = await updateChannel(currentRow.id, { models: next.join(',') })
if (res.success) {
toast.success(
mode === 'replace'
? t('Models updated successfully')
: t('Models appended successfully')
)
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
}
}
const pullModel = async () => {
if (!channelId) return
if (!pullName.trim()) {
toast.error(t('Please enter model name'))
return
}
if (!resolveOllamaBaseUrl(currentRow)) {
toast.error(t('Please set Ollama API Base URL first'))
return
}
pullAbortRef.current?.abort()
const controller = new AbortController()
pullAbortRef.current = controller
setIsPulling(true)
setPullProgress({ status: 'starting', completed: 0, total: 0 })
try {
const response = await fetch('/api/channel/ollama/pull/stream', {
method: 'POST',
credentials: 'include',
headers: {
...getCommonHeaders(),
Accept: 'text/event-stream',
},
body: JSON.stringify({
channel_id: channelId,
model_name: pullName.trim(),
}),
signal: controller.signal,
})
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const eventData = line.slice(6)
if (!eventData) continue
if (eventData === '[DONE]') {
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
}
try {
const data = JSON.parse(eventData)
if (data?.status) {
setPullProgress(data)
} else if (data?.error) {
toast.error(String(data.error))
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
return
} else if (data?.message) {
toast.success(String(data.message))
setPullName('')
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({
queryKey: channelsQueryKeys.lists(),
})
return
}
} catch {
// ignore malformed events
}
}
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
} catch (err: unknown) {
const isAbort =
typeof err === 'object' &&
err !== null &&
'name' in err &&
(err as { name?: unknown }).name === 'AbortError'
if (!isAbort) {
const msg = err instanceof Error ? err.message : ''
toast.error(t('Model pull failed: {{msg}}', { msg }))
}
setIsPulling(false)
setPullProgress(null)
pullAbortRef.current = null
}
}
const deleteModel = async (modelName: string) => {
if (!channelId) return
try {
setIsDeleting(true)
const payload = await deleteOllamaModel({
channel_id: Number(channelId),
model_name: modelName,
})
if (payload?.success) {
toast.success(t('Model deleted'))
await fetchOllamaModels()
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
setDeleteOpen(false)
setDeleteTarget(null)
} else {
toast.error(payload?.message || t('Failed to delete model'))
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : undefined
toast.error(msg || t('Failed to delete model'))
} finally {
setIsDeleting(false)
}
}
const close = () => {
pullAbortRef.current?.abort()
pullAbortRef.current = null
onOpenChange(false)
}
if (!open) return null
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog
open={deleteOpen}
onOpenChange={(v) => {
setDeleteOpen(v)
if (!v) setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('Delete model "{{name}}"? This cannot be undone.', {
name: deleteTarget || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={isDeleting || !deleteTarget}
onClick={() => {
if (!deleteTarget) return
void deleteModel(deleteTarget)
}}
>
{isDeleting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}
@@ -0,0 +1,150 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface StatusCodeRiskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
detailItems: string[]
onConfirm: () => void
}
const CHECKLIST_KEYS = [
'High-risk status code retry risk check 1',
'High-risk status code retry risk check 2',
'High-risk status code retry risk check 3',
'High-risk status code retry risk check 4',
] as const
export function StatusCodeRiskDialog({
open,
onOpenChange,
detailItems,
onConfirm,
}: StatusCodeRiskDialogProps) {
const { t } = useTranslation()
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
const [confirmText, setConfirmText] = useState('')
const requiredText = t('High-risk status code retry confirmation text')
const allChecked = checkedItems.size === CHECKLIST_KEYS.length
const textMatches = confirmText.trim() === requiredText.trim()
const canConfirm = allChecked && textMatches
const handleConfirm = () => {
if (!canConfirm) return
setCheckedItems(new Set())
setConfirmText('')
onConfirm()
}
const handleCancel = () => {
setCheckedItems(new Set())
setConfirmText('')
onOpenChange(false)
}
const toggleCheck = (idx: number) => {
setCheckedItems((prev) => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx)
else next.add(idx)
return next
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button
variant='destructive'
disabled={!canConfirm}
onClick={handleConfirm}
>
{t('I confirm enabling high-risk retry')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,286 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2, AlertCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
editTagChannels,
getAllModels,
getGroups,
} from '../../api'
import { channelsQueryKeys } from '../../lib'
import type { TagOperationParams } from '../../types'
import { useChannels } from '../channels-provider'
import { ModelMappingEditor } from '../model-mapping-editor'
type TagBatchEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function TagBatchEditDialog({
open,
onOpenChange,
}: TagBatchEditDialogProps) {
const { t } = useTranslation()
const { currentTag } = useChannels()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form fields
const [newTag, setNewTag] = useState('')
const [models, setModels] = useState('')
const [modelMapping, setModelMapping] = useState('')
const [groups, setGroups] = useState<string[]>([])
// Fetch available groups
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
})
// Transform groups to multi-select options
const groupOptions = useMemo(() => {
if (!groupsData?.data) return []
const allGroups = new Set([...groupsData.data, ...groups])
return Array.from(allGroups).map((group) => ({
value: group,
label: group,
}))
}, [groupsData, groups])
useEffect(() => {
if (open && currentTag) {
loadTagData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentTag])
const loadTagData = async () => {
if (!currentTag) return
setIsLoading(true)
try {
// Fetch current tag models
const tagModelsResponse = await getTagModels(currentTag)
if (tagModelsResponse.success && tagModelsResponse.data) {
setModels(tagModelsResponse.data)
}
// Fetch all available models (for future use if needed)
const allModelsResponse = await getAllModels()
if (allModelsResponse.success && allModelsResponse.data) {
// Available models could be used for autocomplete in the future
}
// Initialize new tag with current tag name
setNewTag(currentTag)
} catch (_error: unknown) {
toast.error(
_error instanceof Error ? _error.message : t('Failed to load tag data')
)
} finally {
setIsLoading(false)
}
}
const handleSave = async () => {
if (!currentTag) return
// Validate model mapping JSON if provided
if (modelMapping.trim()) {
try {
JSON.parse(modelMapping)
} catch (_error) {
toast.error(t('Model mapping must be valid JSON'))
return
}
}
setIsSaving(true)
try {
const params: Record<string, string | undefined> = {
tag: currentTag,
}
if (newTag !== currentTag) {
params.new_tag = newTag || undefined
}
if (models.trim()) {
params.models = models
}
if (modelMapping.trim()) {
params.model_mapping = modelMapping
}
if (groups.length > 0) {
params.groups = groups.join(',')
}
// Check if there are any changes
if (Object.keys(params).length === 1) {
toast.warning(t('No changes made'))
return
}
const response = await editTagChannels(
params as unknown as TagOperationParams
)
if (response.success) {
toast.success(t('Tag updated successfully'))
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
handleClose()
} else {
toast.error(response.message || t('Failed to update tag'))
}
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to update tag')
)
} finally {
setIsSaving(false)
}
}
const handleClose = () => {
setNewTag('')
setModels('')
setModelMapping('')
setGroups([])
onOpenChange(false)
}
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,289 @@
import { useState, useMemo } from 'react'
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
open: boolean
addModels: string[]
removeModels: string[]
preferredTab: 'add' | 'remove'
confirmLoading: boolean
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
onCancel: () => void
}
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState(props.preferredTab)
const [searchAdd, setSearchAdd] = useState('')
const [searchRemove, setSearchRemove] = useState('')
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
() => new Set(props.addModels)
)
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
() => new Set(props.removeModels)
)
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
const filteredAdd = useMemo(
() =>
props.addModels.filter((m) =>
m.toLowerCase().includes(searchAdd.toLowerCase())
),
[props.addModels, searchAdd]
)
const filteredRemove = useMemo(
() =>
props.removeModels.filter((m) =>
m.toLowerCase().includes(searchRemove.toLowerCase())
),
[props.removeModels, searchRemove]
)
const toggleModel = (
model: string,
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const next = new Set(set)
if (next.has(model)) next.delete(model)
else next.add(model)
setter(next)
}
const toggleAllVisible = (
models: string[],
set: Set<string>,
setter: (s: Set<string>) => void
) => {
const allSelected = models.every((m) => set.has(m))
const next = new Set(set)
if (allSelected) {
models.forEach((m) => next.delete(m))
} else {
models.forEach((m) => next.add(m))
}
setter(next)
}
const handleConfirm = () => {
const hasAdd = props.addModels.length > 0
const hasRemove = props.removeModels.length > 0
const selectedAddArr = Array.from(selectedAdd)
const selectedRemoveArr = Array.from(selectedRemove)
const anyAdd = selectedAddArr.length > 0
const anyRemove = selectedRemoveArr.length > 0
if (hasAdd && hasRemove && (!anyAdd || !anyRemove)) {
setPartialConfirmOpen(true)
return
}
props.onConfirm({
addModels: selectedAddArr,
removeModels: selectedRemoveArr,
})
}
return (
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={
props.confirmLoading ||
(selectedAdd.size === 0 && selectedRemove.size === 0)
}
>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={partialConfirmOpen}
onOpenChange={setPartialConfirmOpen}
title={t('Partial Submission')}
desc={t(
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
)}
handleConfirm={() => {
setPartialConfirmOpen(false)
props.onConfirm({
addModels: Array.from(selectedAdd),
removeModels: Array.from(selectedRemove),
})
}}
/>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
import { useState, useEffect } from 'react'
import { Code, Table, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
type ModelMappingEditorProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
}
type MappingRow = {
id: string
from: string
to: string
}
export function ModelMappingEditor({
value,
onChange,
disabled = false,
}: ModelMappingEditorProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [rows, setRows] = useState<MappingRow[]>([])
const [jsonValue, setJsonValue] = useState(value)
const parseJsonToRows = (json: string) => {
try {
if (!json.trim()) {
setRows([])
return
}
const parsed = JSON.parse(json)
const newRows: MappingRow[] = Object.entries(parsed).map(
([from, to], index) => ({
id: `${Date.now()}-${index}`,
from,
to: String(to),
})
)
setRows(newRows)
} catch (_error) {
// Invalid JSON, keep current rows
}
}
// Parse JSON to rows when value changes externally
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setJsonValue(value)
parseJsonToRows(value)
}, [value])
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
if (updatedRows.length === 0) {
return ''
}
const obj: Record<string, string> = {}
updatedRows.forEach((row) => {
if (row.from.trim()) {
obj[row.from.trim()] = row.to.trim()
}
})
return JSON.stringify(obj, null, 2)
}
const handleAddRow = () => {
const newRow: MappingRow = {
id: `${Date.now()}`,
from: '',
to: '',
}
const updatedRows = [...rows, newRow]
setRows(updatedRows)
}
const handleDeleteRow = (id: string) => {
const updatedRows = rows.filter((row) => row.id !== id)
setRows(updatedRows)
const json = convertRowsToJson(updatedRows)
setJsonValue(json)
onChange(json)
}
const handleRowChange = (
id: string,
field: 'from' | 'to',
newValue: string
) => {
const updatedRows = rows.map((row) =>
row.id === id ? { ...row, [field]: newValue } : row
)
setRows(updatedRows)
const json = convertRowsToJson(updatedRows)
setJsonValue(json)
onChange(json)
}
const handleJsonChange = (newJson: string) => {
setJsonValue(newJson)
onChange(newJson)
parseJsonToRows(newJson)
}
const handleFillTemplate = () => {
const template = JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
setJsonValue(template)
onChange(template)
parseJsonToRows(template)
}
const toggleMode = () => {
if (mode === 'visual') {
// Switching to JSON mode: sync rows to JSON
const json = convertRowsToJson(rows)
setJsonValue(json)
onChange(json)
setMode('json')
} else {
// Switching to visual mode: sync JSON to rows
parseJsonToRows(jsonValue)
setMode('visual')
}
}
return (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={toggleMode}
disabled={disabled}
>
{mode === 'visual' ? (
<>
<Code className='mr-2 h-4 w-4' />
{t('JSON Mode')}
</>
) : (
<>
<Table className='mr-2 h-4 w-4' />
{t('Visual Mode')}
</>
)}
</Button>
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
onClick={handleFillTemplate}
disabled={disabled}
>
{t('Fill Template')}
</Button>
</div>
</div>
{mode === 'visual' ? (
<div className='space-y-2'>
{rows.length > 0 ? (
<div className='space-y-2'>
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
<div>{t('Original Model')}</div>
<div>{t('Replacement Model')}</div>
<div className='w-10'></div>
</div>
{rows.map((row) => (
<div
key={row.id}
className='grid grid-cols-[1fr_1fr_auto] gap-2'
>
<Input
value={row.from}
onChange={(e) =>
handleRowChange(row.id, 'from', e.target.value)
}
placeholder='gpt-3.5-turbo'
disabled={disabled}
/>
<Input
value={row.to}
onChange={(e) =>
handleRowChange(row.id, 'to', e.target.value)
}
placeholder='gpt-3.5-turbo-0125'
disabled={disabled}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => handleDeleteRow(row.id)}
disabled={disabled}
className='h-10 w-10'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
) : (
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
{t(
'No model mappings configured. Click "Add Mapping" to get started.'
)}
</div>
)}
<Button
type='button'
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={disabled}
className='w-full'
>
<Plus className='mr-2 h-4 w-4' />
{t('Add Mapping')}
</Button>
</div>
) : (
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={t('{"original-model": "replacement-model"}')}
disabled={disabled}
rows={8}
className={cn('font-mono text-sm')}
/>
)}
</div>
)
}
@@ -0,0 +1,177 @@
import { useState, useEffect, useRef } from 'react'
import { Minus, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
interface NumericSpinnerInputProps {
value: number | null | undefined
onChange: (value: number) => void
min?: number
max?: number
step?: number
disabled?: boolean
className?: string
label?: string
}
export function NumericSpinnerInput({
value,
onChange,
min = 0,
max,
step = 1,
disabled = false,
className,
label,
}: NumericSpinnerInputProps) {
const [localValue, setLocalValue] = useState(String(value ?? 0))
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLocalValue(String(value ?? 0))
}
}, [value, editing])
const clamp = (v: number) => {
let result = v
if (min !== undefined) result = Math.max(min, result)
if (max !== undefined) result = Math.min(max, result)
return result
}
const handleIncrement = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled) return
const next = clamp((Number(localValue) || 0) + step)
setLocalValue(String(next))
onChange(next)
}
const handleDecrement = (e: React.MouseEvent) => {
e.stopPropagation()
if (disabled) return
const next = clamp((Number(localValue) || 0) - step)
setLocalValue(String(next))
onChange(next)
}
const handleStartEdit = () => {
if (disabled) return
setEditing(true)
requestAnimationFrame(() => inputRef.current?.select())
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === '' || raw === '-') {
setLocalValue(raw)
return
}
if (!/^-?\d+$/.test(raw)) return
setLocalValue(raw)
}
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
if (isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
const clamped = clamp(num)
setLocalValue(String(clamped))
if (clamped !== (value ?? 0)) {
onChange(clamped)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
commitValue()
} else if (e.key === 'Escape') {
setEditing(false)
setLocalValue(String(value ?? 0))
}
}
const atMin = min !== undefined && Number(localValue) <= min
const atMax = max !== undefined && Number(localValue) >= max
return (
<div className={cn('inline-flex items-center', className)}>
{label && (
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
)}
<div
className={cn(
'group/spinner inline-flex h-7 items-center gap-0 rounded-md transition-colors',
!disabled && 'hover:bg-muted/60',
editing && 'bg-muted/60 ring-primary/30 ring-1'
)}
>
<button
type='button'
tabIndex={-1}
aria-label='Decrement'
onClick={handleDecrement}
disabled={disabled || atMin}
className={cn(
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-l-md transition-colors',
!disabled &&
!atMin &&
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
(disabled || atMin) && 'group-hover/spinner:opacity-30'
)}
>
<Minus className='size-3' />
</button>
{editing ? (
<input
ref={inputRef}
type='text'
value={localValue}
onChange={handleInputChange}
onBlur={commitValue}
onKeyDown={handleKeyDown}
className='h-7 w-10 bg-transparent text-center font-mono text-sm outline-none'
autoFocus
/>
) : (
<button
type='button'
onClick={handleStartEdit}
disabled={disabled}
className={cn(
'h-7 min-w-8 cursor-text px-1 text-center font-mono text-sm tabular-nums',
disabled && 'cursor-default opacity-50'
)}
>
{localValue}
</button>
)}
<button
type='button'
tabIndex={-1}
aria-label='Increment'
onClick={handleIncrement}
disabled={disabled || atMax}
className={cn(
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-r-md transition-colors',
!disabled &&
!atMax &&
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
(disabled || atMax) && 'group-hover/spinner:opacity-30'
)}
>
<Plus className='size-3' />
</button>
</div>
</div>
)
}
+368
View File
@@ -0,0 +1,368 @@
// ============================================================================
// Channel Types (from constant/channel.go)
// All label/name values are i18n keys; use t(value) when displaying.
// ============================================================================
export const CHANNEL_TYPES = {
0: 'Unknown',
1: 'OpenAI',
2: 'Midjourney',
3: 'Azure',
4: 'Ollama',
5: 'MidjourneyPlus',
6: 'OpenAIMax',
7: 'OhMyGPT',
8: 'Custom',
9: 'AILS',
10: 'AI Proxy',
11: 'PaLM',
12: 'API2GPT',
13: 'AIGC2D',
14: 'Anthropic',
15: 'Baidu',
16: 'Zhipu',
17: 'Ali',
18: 'Xunfei',
19: '360',
20: 'OpenRouter',
21: 'AI Proxy Library',
22: 'FastGPT',
23: 'Tencent',
24: 'Gemini',
25: 'Moonshot',
26: 'Zhipu V4',
27: 'Perplexity',
31: 'LingYiWanWu',
33: 'AWS',
34: 'Cohere',
35: 'MiniMax',
36: 'SunoAPI',
37: 'Dify',
38: 'Jina',
39: 'Cloudflare',
40: 'SiliconFlow',
41: 'Vertex AI',
42: 'Mistral',
43: 'DeepSeek',
44: 'MokaAI',
45: 'VolcEngine',
46: 'Baidu V2',
47: 'Xinference',
48: 'xAI',
49: 'Coze',
50: 'Kling',
51: 'Jimeng',
52: 'Vidu',
53: 'Submodel',
54: 'DoubaoVideo',
55: 'Sora',
56: 'Replicate',
57: 'Codex',
} as const
export const CHANNEL_TYPE_OPTIONS = Object.entries(CHANNEL_TYPES)
.filter(([value]) => {
const num = Number(value)
return num !== 0 // Exclude Unknown
})
.map(([value, label]) => ({
value: Number(value),
label,
}))
// ============================================================================
// Channel Status (label values are i18n keys; use t(config.label) in components)
// ============================================================================
export const CHANNEL_STATUS = {
UNKNOWN: 0,
ENABLED: 1,
MANUAL_DISABLED: 2,
AUTO_DISABLED: 3,
} as const
export const CHANNEL_STATUS_LABELS = {
[CHANNEL_STATUS.UNKNOWN]: 'Unknown',
[CHANNEL_STATUS.ENABLED]: 'Enabled',
[CHANNEL_STATUS.MANUAL_DISABLED]: 'Disabled',
[CHANNEL_STATUS.AUTO_DISABLED]: 'Auto Disabled',
} as const
export const CHANNEL_STATUS_OPTIONS = [
{ value: 'all', label: 'All Status' },
{ value: 'enabled', label: 'Enabled' },
{ value: 'disabled', label: 'Disabled' },
] as const
export const CHANNEL_STATUS_CONFIG = {
[CHANNEL_STATUS.UNKNOWN]: {
variant: 'neutral' as const,
label: 'Unknown',
showDot: true,
},
[CHANNEL_STATUS.ENABLED]: {
variant: 'success' as const,
label: 'Enabled',
showDot: true,
},
[CHANNEL_STATUS.MANUAL_DISABLED]: {
variant: 'neutral' as const,
label: 'Disabled',
showDot: true,
},
[CHANNEL_STATUS.AUTO_DISABLED]: {
variant: 'danger' as const,
label: 'Auto Disabled',
showDot: true,
},
}
// ============================================================================
// Multi-Key Status
// ============================================================================
export const MULTI_KEY_STATUS = {
ENABLED: 1,
MANUAL_DISABLED: 2,
AUTO_DISABLED: 3,
} as const
export const MULTI_KEY_STATUS_LABELS = {
[MULTI_KEY_STATUS.ENABLED]: 'Enabled',
[MULTI_KEY_STATUS.MANUAL_DISABLED]: 'Manual Disabled',
[MULTI_KEY_STATUS.AUTO_DISABLED]: 'Auto Disabled',
} as const
export const MULTI_KEY_STATUS_CONFIG = {
[MULTI_KEY_STATUS.ENABLED]: {
variant: 'success' as const,
label: 'Enabled',
},
[MULTI_KEY_STATUS.MANUAL_DISABLED]: {
variant: 'neutral' as const,
label: 'Manual Disabled',
},
[MULTI_KEY_STATUS.AUTO_DISABLED]: {
variant: 'danger' as const,
label: 'Auto Disabled',
},
}
// ============================================================================
// Multi-Key Modes
// ============================================================================
export const MULTI_KEY_MODES = [
{ value: 'random', label: 'Random' },
{ value: 'polling', label: 'Polling' },
] as const
export const ADD_MODE_OPTIONS = [
{ value: 'single', label: 'Single Key' },
{ value: 'batch', label: 'Batch Add (one key per line)' },
{
value: 'multi_to_single',
label: 'Multi-Key Mode (multiple keys, one channel)',
},
] as const
// ============================================================================
// Multi-Key Management
// ============================================================================
export const MULTI_KEY_FILTER_OPTIONS = [
{ value: 'all', label: 'All Status' },
{ value: '1', label: 'Enabled' },
{ value: '2', label: 'Manual Disabled' },
{ value: '3', label: 'Auto Disabled' },
] as const
export const MULTI_KEY_CONFIRM_MESSAGES = {
DELETE:
'Are you sure you want to delete this key? This action cannot be undone.',
ENABLE: 'Enable this key?',
DISABLE: 'Disable this key?',
ENABLE_ALL: 'Are you sure you want to enable all keys?',
DISABLE_ALL: 'Are you sure you want to disable all enabled keys?',
DELETE_DISABLED:
'Are you sure you want to delete all auto-disabled keys? This action cannot be undone.',
} as const
// ============================================================================
// Auto Ban Options
// ============================================================================
export const AUTO_BAN_OPTIONS = [
{ value: 1, label: 'Enabled' },
{ value: 0, label: 'Disabled' },
] as const
// ============================================================================
// Error / Success Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
// ============================================================================
export const ERROR_MESSAGES = {
REQUIRED_NAME: 'Channel name is required',
REQUIRED_TYPE: 'Channel type is required',
REQUIRED_KEY: 'API key is required',
REQUIRED_MODELS: 'Models are required',
REQUIRED_GROUP: 'Group is required',
INVALID_JSON: 'Invalid JSON format',
INVALID_MODEL_MAPPING: 'Invalid model mapping format',
CREATE_FAILED: 'Failed to create channel',
UPDATE_FAILED: 'Failed to update channel',
DELETE_FAILED: 'Failed to delete channel',
TEST_FAILED: 'Failed to test channel',
BALANCE_QUERY_FAILED: 'Failed to query balance',
FETCH_MODELS_FAILED: 'Failed to fetch models',
} as const
export const SUCCESS_MESSAGES = {
CREATED: 'Channel created successfully',
UPDATED: 'Channel updated successfully',
DELETED: 'Channel deleted successfully',
ENABLED: 'Channel enabled successfully',
DISABLED: 'Channel disabled successfully',
TESTED: 'Channel test completed',
BALANCE_QUERIED: 'Balance queried successfully',
MODELS_FETCHED: 'Models fetched successfully',
COPIED: 'Channel copied successfully',
TAG_SET: 'Tag set successfully',
BATCH_DELETED: 'Channels deleted successfully',
} as const
// ============================================================================
// Default Values
// ============================================================================
export const DEFAULT_PAGE_SIZE = 20
export const DEFAULT_CHANNEL_VALUES = {
name: '',
type: 0,
base_url: '',
key: '',
models: '',
group: 'default',
status: CHANNEL_STATUS.ENABLED,
priority: 0,
weight: 0,
auto_ban: 1,
remark: '',
} as const
// ============================================================================
// Table Configuration
// ============================================================================
export const CHANNELS_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
// ============================================================================
// Sort Options (label values are i18n keys)
// ============================================================================
export const SORT_OPTIONS = [
{ value: 'priority', label: 'Priority (Default)' },
{ value: 'id', label: 'ID' },
{ value: 'name', label: 'Name' },
{ value: 'balance', label: 'Balance' },
{ value: 'response_time', label: 'Response Time' },
] as const
// ============================================================================
// Balance Display
// ============================================================================
export const BALANCE_THRESHOLDS = {
LOW: 1,
MEDIUM: 10,
HIGH: 100,
} as const
// ============================================================================
// Response Time Thresholds (in ms)
// ============================================================================
export const RESPONSE_TIME_THRESHOLDS = {
EXCELLENT: 500,
GOOD: 1000,
FAIR: 2000,
POOR: 5000,
} as const
export const RESPONSE_TIME_CONFIG = {
EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
GOOD: { variant: 'info' as const, label: 'Good' },
FAIR: { variant: 'warning' as const, label: 'Fair' },
POOR: { variant: 'danger' as const, label: 'Poor' },
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
} as const
// ============================================================================
// Field Hints and Placeholders (i18n keys; use t() when displaying)
// ============================================================================
export const FIELD_PLACEHOLDERS = {
NAME: 'e.g., OpenAI GPT-4 Production',
BASE_URL: 'Leave empty to use default',
KEY: 'API Key (one per line for batch mode)',
MODELS: 'Comma-separated model names, e.g., gpt-4,gpt-3.5-turbo',
GROUP: 'Please Select user groups that can access this channel.',
MODEL_MAPPING: '{"request_model": "actual_model"}',
TEST_MODEL: 'Model to use for testing',
TAG: 'Optional tag for grouping channels',
REMARK: 'Optional notes about this channel',
PARAM_OVERRIDE: '{"temperature": 0.7}',
HEADER_OVERRIDE: '{"X-Custom-Header": "value"}',
STATUS_CODE_MAPPING: '{"400": "500"}',
} as const
export const FIELD_DESCRIPTIONS = {
NAME: 'Friendly name to identify this channel',
TYPE: 'Provider type (OpenAI, Anthropic, etc.)',
BASE_URL: 'Custom API base URL. Leave empty to use provider default.',
KEY: 'API key from the provider',
MODELS:
'List of models supported by this channel. Use comma to separate multiple models.',
GROUP: 'User groups that can access this channel. ',
MODEL_MAPPING:
'Map request model names to actual provider model names (JSON format)',
PRIORITY: 'Higher priority channels are selected first',
WEIGHT: 'Used for load balancing. Higher weight = more requests',
TEST_MODEL: 'Model to use when testing channel connectivity',
AUTO_BAN: 'Automatically disable channel on repeated failures',
STATUS_CODE_MAPPING: 'Map response status codes (JSON format)',
TAG: 'Group channels by tag for batch operations',
REMARK: 'Internal notes (not shown to users)',
SETTING: 'Channel-specific settings (JSON format)',
PARAM_OVERRIDE: 'Override request parameters (JSON format)',
HEADER_OVERRIDE: 'Override request headers (JSON format)',
MULTI_KEY_MODE: 'How to select keys: random or sequential polling',
BATCH_ADD: 'Create multiple channels from multiple keys',
OPENAI_ORG: 'OpenAI Organization ID (optional)',
} as const
// ============================================================================
// Channel Type Specific Configurations
// ============================================================================
export const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 17, 20, 23, 24, 25, 26, 31, 34, 35, 40, 42, 43, 47, 48,
])
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
15: 'Format: APIKey|SecretKey',
18: 'Format: APPID|APISecret|APIKey',
22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041',
23: 'Format: AppId|SecretId|SecretKey',
33: 'Format: Ak|Sk|Region',
50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
51: 'Format: Access Key ID|Secret Access Key',
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
}
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
3: 'For channels added after May 10, 2025, no need to remove "." from model names during deployment',
8: 'If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing',
37: 'Dify channels only support chatflow and agent, and agent does not support images',
}
@@ -0,0 +1,281 @@
import { useRef, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { normalizeModelList } from '../lib/upstream-update-utils'
function getManualIgnoredModelCount(settings: unknown): number {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object')
parsed = settings as Record<string, unknown>
else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
parsed = null
}
}
if (!parsed) return 0
return normalizeModelList(
(parsed.upstream_model_update_ignored_models as unknown[]) || []
).length
}
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const [channel, setChannel] = useState<{
id: number
[key: string]: unknown
} | null>(null)
const [addModels, setAddModels] = useState<string[]>([])
const [removeModels, setRemoveModels] = useState<string[]>([])
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
const [applyLoading, setApplyLoading] = useState(false)
const [detectAllLoading, setDetectAllLoading] = useState(false)
const [applyAllLoading, setApplyAllLoading] = useState(false)
const applyRef = useRef(false)
const detectRef = useRef(false)
const detectAllRef = useRef(false)
const applyAllRef = useRef(false)
const openModal = useCallback(
(
record: { id: number; [key: string]: unknown } | null,
pendingAdd: string[] = [],
pendingRemove: string[] = [],
tab: 'add' | 'remove' = 'add'
) => {
const normAdd = normalizeModelList(pendingAdd)
const normRemove = normalizeModelList(pendingRemove)
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
toast.info(t('No processable upstream model updates for this channel'))
return
}
setChannel(record)
setAddModels(normAdd)
setRemoveModels(normRemove)
setPreferredTab(tab)
setShowModal(true)
},
[t]
)
const closeModal = useCallback(() => {
setShowModal(false)
setChannel(null)
setAddModels([])
setRemoveModels([])
setPreferredTab('add')
}, [])
const applyUpdates = useCallback(
async ({
addModels: selectedAdd = [],
removeModels: selectedRemove = [],
}: {
addModels?: string[]
removeModels?: string[]
} = {}) => {
if (applyRef.current) return
if (!channel?.id) {
closeModal()
return
}
applyRef.current = true
setApplyLoading(true)
try {
const normSelectedAdd = normalizeModelList(selectedAdd)
const selectedAddSet = new Set(normSelectedAdd)
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
const res = await api.post(
'/api/channel/upstream_updates/apply',
{
id: channel.id,
add_models: normSelectedAdd,
ignore_models: ignoreModels,
remove_models: normalizeModelList(selectedRemove),
},
{ skipErrorHandler: true } as Record<string, unknown>
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Operation failed'))
return
}
toast.success(
t(
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
{
added: data?.added_models?.length || 0,
removed: data?.removed_models?.length || 0,
ignored: normalizeModelList(ignoreModels).length,
totalIgnored: getManualIgnoredModelCount(data?.settings),
}
)
)
closeModal()
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Operation failed')
)
} finally {
applyRef.current = false
setApplyLoading(false)
}
},
[channel, addModels, closeModal, refresh, t]
)
const applyAllUpdates = useCallback(async () => {
if (applyAllRef.current) return
applyAllRef.current = true
setApplyAllLoading(true)
try {
const res = await api.post(
'/api/channel/upstream_updates/apply_all',
{},
{ skipErrorHandler: true } as Record<string, unknown>
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Batch processing failed'))
return
}
toast.success(
t(
'Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed',
{
channels: data?.processed_channels || 0,
added: data?.added_models || 0,
removed: data?.removed_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
)
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch processing failed')
)
} finally {
applyAllRef.current = false
setApplyAllLoading(false)
}
}, [refresh, t])
const detectChannelUpdates = useCallback(
async (ch: { id: number; [key: string]: unknown } | null) => {
if (detectRef.current || !ch?.id) return
detectRef.current = true
try {
const res = await api.post(
'/api/channel/upstream_updates/detect',
{ id: ch.id },
{ skipErrorHandler: true } as Record<string, unknown>
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Detection failed'))
return
}
toast.success(
t('Detection complete: {{add}} to add, {{remove}} to remove', {
add: data?.add_models?.length || 0,
remove: data?.remove_models?.length || 0,
})
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message || err?.message || t('Detection failed')
)
} finally {
detectRef.current = false
}
},
[refresh, t]
)
const detectAllUpdates = useCallback(async () => {
if (detectAllRef.current) return
detectAllRef.current = true
setDetectAllLoading(true)
try {
const res = await api.post(
'/api/channel/upstream_updates/detect_all',
{},
{ skipErrorHandler: true } as Record<string, unknown>
)
const { success, message, data } = res.data || {}
if (!success) {
toast.error(message || t('Batch detection failed'))
return
}
toast.success(
t(
'Batch detection complete: {{channels}} channels, {{add}} to add, {{remove}} to remove, {{fails}} failed',
{
channels: data?.processed_channels || 0,
add: data?.detected_add_models || 0,
remove: data?.detected_remove_models || 0,
fails: (data?.failed_channel_ids || []).length,
}
)
)
await refresh()
} catch (e: unknown) {
const err = e as {
response?: { data?: { message?: string } }
message?: string
}
toast.error(
err?.response?.data?.message ||
err?.message ||
t('Batch detection failed')
)
} finally {
detectAllRef.current = false
setDetectAllLoading(false)
}
}, [refresh, t])
return {
showModal,
channel,
addModels,
removeModels,
preferredTab,
applyLoading,
detectAllLoading,
applyAllLoading,
openModal,
closeModal,
applyUpdates,
applyAllUpdates,
detectChannelUpdates,
detectAllUpdates,
}
}
+28
View File
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { ChannelsDialogs } from './components/channels-dialogs'
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
import { ChannelsProvider } from './components/channels-provider'
import { ChannelsTable } from './components/channels-table'
export function Channels() {
const { t } = useTranslation()
return (
<ChannelsProvider>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage API channels and provider configurations')}
</SectionPageLayout.Description>
<SectionPageLayout.Actions>
<ChannelsPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<ChannelsTable />
</SectionPageLayout.Content>
</SectionPageLayout>
<ChannelsDialogs />
</ChannelsProvider>
)
}
+577
View File
@@ -0,0 +1,577 @@
import type { QueryClient } from '@tanstack/react-query'
import i18next from 'i18next'
import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import {
copyChannel,
deleteChannel,
testChannel,
updateChannel,
batchDeleteChannels,
batchSetChannelTag,
enableTagChannels,
disableTagChannels,
deleteDisabledChannels,
fixChannelAbilities,
editTagChannels,
testAllChannels,
updateAllChannelsBalance,
updateChannelBalance,
} from '../api'
import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import type { CopyChannelParams } from '../types'
// ============================================================================
// Query Keys
// ============================================================================
export const channelsQueryKeys = {
all: ['channels'] as const,
lists: () => [...channelsQueryKeys.all, 'list'] as const,
list: (params: Record<string, unknown>) =>
[...channelsQueryKeys.lists(), params] as const,
details: () => [...channelsQueryKeys.all, 'detail'] as const,
detail: (id: number) => [...channelsQueryKeys.details(), id] as const,
}
// ============================================================================
// Single Channel Actions
// ============================================================================
/**
* Enable a channel
*/
export async function handleEnableChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.ENABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Disable a channel
*/
export async function handleDisableChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, {
status: CHANNEL_STATUS.MANUAL_DISABLED,
})
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.DISABLED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Toggle channel status (enable/disable)
*/
export async function handleToggleChannelStatus(
id: number,
currentStatus: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (currentStatus === CHANNEL_STATUS.ENABLED) {
await handleDisableChannel(id, queryClient, onSuccess)
} else {
await handleEnableChannel(id, queryClient, onSuccess)
}
}
/**
* Delete a channel
*/
export async function handleDeleteChannel(
id: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await deleteChannel(id)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.DELETED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
/**
* Update a specific channel field (e.g., priority, weight)
*/
export async function handleUpdateChannelField(
id: number,
fieldName: string,
value: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateChannel(id, { [fieldName]: value })
if (response.success) {
// Show success toast with field name
const fieldLabel =
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
toast.success(
i18next.t('{{field}} updated to {{value}}', {
field: fieldLabel,
value,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Update a specific field for all channels with a tag
*/
export async function handleUpdateTagField(
tag: string,
fieldName: 'priority' | 'weight',
value: number,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const params = { tag, [fieldName]: value }
const response = await editTagChannels(params)
if (response.success) {
// Show success toast with field name
const fieldLabel =
fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase()
toast.success(
i18next.t('{{field}} updated to {{value}} for tag: {{tag}}', {
field: fieldLabel,
value,
tag,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.UPDATE_FAILED))
}
}
/**
* Test channel connectivity
*/
export async function handleTestChannel(
id: number,
options?: { testModel?: string; endpointType?: string; stream?: boolean },
onTestComplete?: (
success: boolean,
responseTime?: number,
error?: string,
errorCode?: string
) => void
): Promise<void> {
const payload =
options && (options.testModel || options.endpointType || options.stream)
? {
...(options.testModel ? { model: options.testModel } : {}),
...(options.endpointType
? { endpoint_type: options.endpointType }
: {}),
...(options.stream ? { stream: true } : {}),
}
: undefined
try {
const response = await testChannel(id, payload)
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.TESTED))
onTestComplete?.(true, response.data?.response_time)
} else {
toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED))
onTestComplete?.(false, undefined, response.message, response.error_code)
}
} catch (_error: unknown) {
const err = _error as { response?: { data?: { message?: string } } }
const errorMsg =
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
toast.error(errorMsg)
onTestComplete?.(false, undefined, errorMsg)
}
}
/**
* Copy a channel
*/
export async function handleCopyChannel(
id: number,
params: CopyChannelParams,
queryClient?: QueryClient,
onSuccess?: (newId: number) => void
): Promise<void> {
try {
const response = await copyChannel(id, params)
if (response.success && response.data?.id) {
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data.id)
}
} catch (_error) {
toast.error(i18next.t('Failed to copy channel'))
}
}
/**
* Update channel balance
*/
export async function handleUpdateChannelBalance(
id: number,
queryClient?: QueryClient,
onSuccess?: (balance: number) => void
): Promise<void> {
try {
const response = await updateChannelBalance(id)
if (response.success && response.balance !== undefined) {
const balance = response.balance
toast.success(
i18next.t('Balance updated: {{balance}}', {
balance: formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
}),
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(balance)
} else {
toast.error(response.message || i18next.t('Failed to update balance'))
}
} catch (_error: unknown) {
toast.error(
_error instanceof Error
? _error.message
: i18next.t('Failed to update balance')
)
}
}
// ============================================================================
// Batch Actions
// ============================================================================
/**
* Batch delete channels
*/
export async function handleBatchDelete(
ids: number[],
queryClient?: QueryClient,
onSuccess?: (deletedCount: number) => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchDeleteChannels({ ids })
if (response.success) {
toast.success(
i18next.t('{{count}} channel(s) deleted', {
count: response.data || ids.length,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || ids.length)
}
} catch (_error) {
toast.error(i18next.t(ERROR_MESSAGES.DELETE_FAILED))
}
}
/**
* Batch enable channels
*/
export async function handleBatchEnable(
ids: number[],
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
// Update each channel individually
const promises = ids.map((id) =>
updateChannel(id, { status: CHANNEL_STATUS.ENABLED })
)
const results = await Promise.allSettled(promises)
const successCount = results.filter((r) => r.status === 'fulfilled').length
const failCount = results.filter((r) => r.status === 'rejected').length
if (successCount > 0) {
toast.success(
i18next.t('{{count}} channel(s) enabled', { count: successCount })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to enable', { count: failCount })
)
}
} catch (_error) {
toast.error(i18next.t('Failed to enable channels'))
}
}
/**
* Batch disable channels
*/
export async function handleBatchDisable(
ids: number[],
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
// Update each channel individually
const promises = ids.map((id) =>
updateChannel(id, { status: CHANNEL_STATUS.MANUAL_DISABLED })
)
const results = await Promise.allSettled(promises)
const successCount = results.filter((r) => r.status === 'fulfilled').length
const failCount = results.filter((r) => r.status === 'rejected').length
if (successCount > 0) {
toast.success(
i18next.t('{{count}} channel(s) disabled', { count: successCount })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
if (failCount > 0) {
toast.error(
i18next.t('{{count}} channel(s) failed to disable', {
count: failCount,
})
)
}
} catch (_error) {
toast.error(i18next.t('Failed to disable channels'))
}
}
/**
* Batch set tag
*/
export async function handleBatchSetTag(
ids: number[],
tag: string | null,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
if (ids.length === 0) {
toast.error(i18next.t('No channels selected'))
return
}
try {
const response = await batchSetChannelTag({ ids, tag })
if (response.success) {
toast.success(i18next.t(SUCCESS_MESSAGES.TAG_SET))
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t('Failed to set tag'))
}
}
// ============================================================================
// Tag-Based Actions
// ============================================================================
/**
* Enable all channels with a tag
*/
export async function handleEnableTagChannels(
tag: string,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await enableTagChannels(tag)
if (response.success) {
toast.success(
i18next.t('Enabled all channels with tag: {{tag}}', { tag })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t('Failed to enable tag channels'))
}
}
/**
* Disable all channels with a tag
*/
export async function handleDisableTagChannels(
tag: string,
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await disableTagChannels(tag)
if (response.success) {
toast.success(
i18next.t('Disabled all channels with tag: {{tag}}', { tag })
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
}
} catch (_error) {
toast.error(i18next.t('Failed to disable tag channels'))
}
}
// ============================================================================
// System Actions
// ============================================================================
/**
* Delete all disabled channels
*/
export async function handleDeleteAllDisabled(
queryClient?: QueryClient,
onSuccess?: (deletedCount: number) => void
): Promise<void> {
try {
const response = await deleteDisabledChannels()
if (response.success) {
toast.success(
i18next.t('{{count}} disabled channel(s) deleted', {
count: response.data || 0,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data || 0)
}
} catch (_error) {
toast.error(i18next.t('Failed to delete disabled channels'))
}
}
/**
* Fix channel abilities
*/
export async function handleFixAbilities(
queryClient?: QueryClient,
onSuccess?: (result: { success: number; fails: number }) => void
): Promise<void> {
try {
const response = await fixChannelAbilities()
if (response.success && response.data) {
toast.success(
i18next.t('Fixed abilities: {{success}} succeeded, {{fails}} failed', {
success: response.data.success,
fails: response.data.fails,
})
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.(response.data)
}
} catch (_error) {
toast.error(i18next.t('Failed to fix abilities'))
}
}
/**
* Test all enabled channels
*/
export async function handleTestAllChannels(
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await testAllChannels()
if (response.success) {
toast.success(
i18next.t(
'Testing all enabled channels started. Please refresh to see results.'
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to start testing all channels')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to test all channels'))
}
}
/**
* Update balance for all enabled channels
*/
export async function handleUpdateAllBalances(
queryClient?: QueryClient,
onSuccess?: () => void
): Promise<void> {
try {
const response = await updateAllChannelsBalance()
if (response.success) {
toast.success(
i18next.t(
'Updating all channel balances. This may take a while. Please refresh to see results.'
)
)
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
onSuccess?.()
} else {
toast.error(
response.message || i18next.t('Failed to update all balances')
)
}
} catch (_error) {
toast.error(i18next.t('Failed to update all balances'))
}
}
+502
View File
@@ -0,0 +1,502 @@
import { z } from 'zod'
import { CHANNEL_STATUS, MODEL_FETCHABLE_TYPES } from '../constants'
import type { Channel } from '../types'
// ============================================================================
// Form Validation Schema
// ============================================================================
export const channelFormSchema = z.object({
name: z.string().min(1, 'Channel name is required'),
type: z.number().min(0, 'Channel type is required'),
base_url: z.string().optional(),
key: z.string(),
openai_organization: z.string().optional(),
models: z.string().min(1, 'At least one model is required'),
group: z.array(z.string()).min(1, 'At least one group is required'),
model_mapping: z.string().optional(),
priority: z.number().optional(),
weight: z.number().optional(),
test_model: z.string().optional(),
auto_ban: z.number().optional(),
status: z.number(),
status_code_mapping: z.string().optional(),
tag: z.string().optional(),
remark: z
.string()
.max(255, 'Remark must be less than 255 characters')
.optional(),
setting: z.string().optional(),
param_override: z.string().optional(),
header_override: z.string().optional(),
settings: z.string().optional(),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
multi_key_type: z.enum(['random', 'polling']).optional(),
batch_add_set_key_prefix_2_name: z.boolean().optional(),
key_mode: z.enum(['append', 'replace']).optional(), // For editing multi-key channels
// Channel extra settings (stored in setting JSON, not sent directly)
force_format: z.boolean().optional(),
thinking_to_content: z.boolean().optional(),
proxy: z.string().optional(),
pass_through_body_enabled: z.boolean().optional(),
system_prompt: z.string().optional(),
system_prompt_override: z.boolean().optional(),
// Type-specific settings (stored in settings JSON)
is_enterprise_account: z.boolean().optional(), // OpenRouter specific
vertex_key_type: z.enum(['json', 'api_key']).optional(), // Vertex AI specific
aws_key_type: z.enum(['ak_sk', 'api_key']).optional(), // AWS specific
azure_responses_version: z.string().optional(), // Azure specific
// Field passthrough controls (stored in settings JSON)
allow_service_tier: z.boolean().optional(), // OpenAI/Anthropic
disable_store: z.boolean().optional(), // OpenAI only
allow_safety_identifier: z.boolean().optional(), // OpenAI only
allow_include_obfuscation: z.boolean().optional(), // OpenAI: include usage obfuscation
allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography
allow_speed: z.boolean().optional(), // Anthropic: speed mode control
claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough
// Upstream model update settings (stored in settings JSON)
upstream_model_update_check_enabled: z.boolean().optional(),
upstream_model_update_auto_sync_enabled: z.boolean().optional(),
})
export type ChannelFormValues = z.infer<typeof channelFormSchema>
// ============================================================================
// Default Form Values
// ============================================================================
export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
name: '',
type: 1,
base_url: '',
key: '',
openai_organization: '',
models: '',
group: ['default'],
model_mapping: '',
priority: 0,
weight: 0,
test_model: '',
auto_ban: 1,
status: CHANNEL_STATUS.ENABLED,
status_code_mapping: '',
tag: '',
remark: '',
setting: '',
param_override: '',
header_override: '',
settings: '{}',
other: '',
multi_key_mode: 'single',
multi_key_type: 'random',
batch_add_set_key_prefix_2_name: false,
key_mode: 'append',
// Channel extra settings
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
// Type-specific settings
is_enterprise_account: false,
vertex_key_type: 'json',
aws_key_type: 'ak_sk',
azure_responses_version: '',
// Field passthrough controls
allow_service_tier: false,
disable_store: false,
allow_safety_identifier: false,
allow_include_obfuscation: false,
allow_inference_geo: false,
allow_speed: false,
claude_beta_query: false,
}
// ============================================================================
// Transform Functions
// ============================================================================
/**
* Transform Channel from API to Form default values
*/
export function transformChannelToFormDefaults(
channel: Channel
): ChannelFormValues {
// Parse channel extra settings from setting field
let extraSettings = {
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
}
if (channel.setting) {
try {
const parsed = JSON.parse(channel.setting)
extraSettings = {
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
proxy: parsed.proxy || '',
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
system_prompt: parsed.system_prompt || '',
system_prompt_override: parsed.system_prompt_override || false,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel setting:', error)
}
}
// Parse type-specific settings from settings field
let vertexKeyType: 'json' | 'api_key' = 'json'
let azureResponsesVersion = ''
let isEnterpriseAccount = false
let awsKeyType: 'ak_sk' | 'api_key' = 'ak_sk'
let allowServiceTier = false
let disableStore = false
let allowSafetyIdentifier = false
let allowIncludeObfuscation = false
let allowInferenceGeo = false
let allowSpeed = false
let claudeBetaQuery = false
let upstreamModelUpdateCheckEnabled = false
let upstreamModelUpdateAutoSyncEnabled = false
if (channel.settings) {
try {
const parsed = JSON.parse(channel.settings)
vertexKeyType = parsed.vertex_key_type || 'json'
azureResponsesVersion = parsed.azure_responses_version || ''
isEnterpriseAccount = parsed.openrouter_enterprise === true
awsKeyType = parsed.aws_key_type || 'ak_sk'
allowServiceTier = parsed.allow_service_tier === true
disableStore = parsed.disable_store === true
allowSafetyIdentifier = parsed.allow_safety_identifier === true
allowIncludeObfuscation = parsed.allow_include_obfuscation === true
allowInferenceGeo = parsed.allow_inference_geo === true
allowSpeed = parsed.allow_speed === true
claudeBetaQuery = parsed.claude_beta_query === true
upstreamModelUpdateCheckEnabled =
parsed.upstream_model_update_check_enabled === true
upstreamModelUpdateAutoSyncEnabled =
parsed.upstream_model_update_auto_sync_enabled === true
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel settings:', error)
}
}
return {
name: channel.name || '',
type: channel.type,
base_url: channel.base_url || '',
key: '', // Never populate key from backend for security
openai_organization: channel.openai_organization || '',
models: channel.models || '',
group: parseGroups(channel.group || 'default'),
model_mapping: channel.model_mapping || '',
priority: channel.priority || 0,
weight: channel.weight || 0,
test_model: channel.test_model || '',
auto_ban: channel.auto_ban ?? 1,
status: channel.status,
status_code_mapping: channel.status_code_mapping || '',
tag: channel.tag || '',
remark: channel.remark || '',
setting: channel.setting || '',
param_override: channel.param_override || '',
header_override: channel.header_override || '',
settings: channel.settings || '{}',
other: channel.other || '',
multi_key_mode: 'single',
multi_key_type: channel.channel_info.multi_key_mode || 'random',
batch_add_set_key_prefix_2_name: false,
key_mode: 'append', // Default to append mode for editing multi-key channels
// Channel extra settings
...extraSettings,
// Type-specific settings
is_enterprise_account: isEnterpriseAccount,
vertex_key_type: vertexKeyType,
azure_responses_version: azureResponsesVersion,
aws_key_type: awsKeyType,
allow_service_tier: allowServiceTier,
disable_store: disableStore,
allow_include_obfuscation: allowIncludeObfuscation,
allow_inference_geo: allowInferenceGeo,
allow_speed: allowSpeed,
claude_beta_query: claudeBetaQuery,
allow_safety_identifier: allowSafetyIdentifier,
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
}
}
/**
* Build the setting JSON string from form extra settings
*/
function buildSettingJSON(formData: ChannelFormValues): string {
const settingObj = {
force_format: formData.force_format || false,
thinking_to_content: formData.thinking_to_content || false,
proxy: formData.proxy || '',
pass_through_body_enabled: formData.pass_through_body_enabled || false,
system_prompt: formData.system_prompt || '',
system_prompt_override: formData.system_prompt_override || false,
}
return JSON.stringify(settingObj)
}
/**
* Build the settings JSON string (for type-specific config like vertex_key_type)
*/
function buildSettingsJSON(formData: ChannelFormValues): string {
let settingsObj: Record<string, unknown> = {}
// Try to parse existing settings first
if (formData.settings && formData.settings !== '{}') {
try {
settingsObj = JSON.parse(formData.settings)
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse existing settings:', error)
}
}
// Add vertex_key_type for Vertex AI channels (type 41)
if (formData.type === 41) {
settingsObj.vertex_key_type = formData.vertex_key_type || 'json'
} else if ('vertex_key_type' in settingsObj) {
delete settingsObj.vertex_key_type
}
// Add azure_responses_version for Azure channels (type 3)
if (formData.type === 3 && formData.azure_responses_version) {
settingsObj.azure_responses_version = formData.azure_responses_version
} else if ('azure_responses_version' in settingsObj) {
delete settingsObj.azure_responses_version
}
// Add enterprise account setting for OpenRouter (type 20)
if (formData.type === 20) {
settingsObj.openrouter_enterprise = formData.is_enterprise_account === true
} else if ('openrouter_enterprise' in settingsObj) {
delete settingsObj.openrouter_enterprise
}
// Add aws_key_type for AWS channels (type 33)
if (formData.type === 33) {
settingsObj.aws_key_type = formData.aws_key_type || 'ak_sk'
} else if ('aws_key_type' in settingsObj) {
delete settingsObj.aws_key_type
}
// Field passthrough controls:
// - OpenAI (type 1) and Anthropic (type 14): allow_service_tier
// - OpenAI only: disable_store, allow_safety_identifier
if (formData.type === 1 || formData.type === 14) {
settingsObj.allow_service_tier = formData.allow_service_tier === true
} else if ('allow_service_tier' in settingsObj) {
delete settingsObj.allow_service_tier
}
if (formData.type === 1) {
settingsObj.disable_store = formData.disable_store === true
settingsObj.allow_safety_identifier =
formData.allow_safety_identifier === true
settingsObj.allow_include_obfuscation =
formData.allow_include_obfuscation === true
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
} else {
if ('disable_store' in settingsObj) delete settingsObj.disable_store
if ('allow_safety_identifier' in settingsObj)
delete settingsObj.allow_safety_identifier
if ('allow_include_obfuscation' in settingsObj)
delete settingsObj.allow_include_obfuscation
if (formData.type !== 14 && 'allow_inference_geo' in settingsObj)
delete settingsObj.allow_inference_geo
}
// Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed
if (formData.type === 14) {
settingsObj.allow_inference_geo = formData.allow_inference_geo === true
settingsObj.allow_speed = formData.allow_speed === true
settingsObj.claude_beta_query = formData.claude_beta_query === true
} else {
if ('allow_speed' in settingsObj) delete settingsObj.allow_speed
if ('claude_beta_query' in settingsObj) delete settingsObj.claude_beta_query
}
// Upstream model update settings (for model-fetchable channel types)
if (MODEL_FETCHABLE_TYPES.has(formData.type)) {
settingsObj.upstream_model_update_check_enabled =
formData.upstream_model_update_check_enabled === true
settingsObj.upstream_model_update_auto_sync_enabled =
formData.upstream_model_update_auto_sync_enabled === true
}
return JSON.stringify(settingsObj)
}
/**
* Transform form data to API payload for creating channel
*/
export function transformFormDataToCreatePayload(formData: ChannelFormValues): {
mode: 'single' | 'batch' | 'multi_to_single'
multi_key_mode?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
channel: Partial<Channel>
} {
const mode = formData.multi_key_mode || 'single'
const channel: Partial<Channel> = {
name: formData.name,
type: formData.type,
base_url: formData.base_url || null,
key: formData.key,
openai_organization: formData.openai_organization || null,
models: formData.models,
group: formatGroups(formData.group),
model_mapping: formData.model_mapping || null,
priority: formData.priority || null,
weight: formData.weight || null,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status: formData.status,
status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null,
remark: formData.remark || '',
setting: buildSettingJSON(formData),
param_override: formData.param_override || null,
header_override: formData.header_override || null,
settings: buildSettingsJSON(formData),
other: formData.other || '',
}
// Clean up empty strings to null for optional fields
Object.keys(channel).forEach((key) => {
if (channel[key as keyof typeof channel] === '') {
;(channel as Record<string, unknown>)[key] = null
}
})
return {
mode,
multi_key_mode:
mode === 'multi_to_single' ? formData.multi_key_type : undefined,
batch_add_set_key_prefix_2_name:
mode === 'batch' ? formData.batch_add_set_key_prefix_2_name : undefined,
channel,
}
}
/**
* Transform form data to API payload for updating channel
*/
export function transformFormDataToUpdatePayload(
formData: ChannelFormValues,
channelId: number
): Partial<Channel> {
const payload: Partial<Channel> = {
id: channelId,
name: formData.name,
type: formData.type,
base_url: formData.base_url || null,
openai_organization: formData.openai_organization || null,
models: formData.models,
group: formatGroups(formData.group),
model_mapping: formData.model_mapping || null,
priority: formData.priority || null,
weight: formData.weight || null,
test_model: formData.test_model || null,
auto_ban: formData.auto_ban ?? 1,
status: formData.status,
status_code_mapping: formData.status_code_mapping || null,
tag: formData.tag || null,
remark: formData.remark || '',
setting: buildSettingJSON(formData),
param_override: formData.param_override || null,
header_override: formData.header_override || null,
settings: buildSettingsJSON(formData),
other: formData.other || '',
}
// Only include key if it was changed (not empty)
if (formData.key && formData.key.trim()) {
payload.key = formData.key
}
// Clean up empty strings to null for optional fields
Object.keys(payload).forEach((key) => {
if (payload[key as keyof typeof payload] === '') {
;(payload as Record<string, unknown>)[key] = null
}
})
return payload
}
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Validate JSON string
*/
export function validateJSON(value: string): boolean {
if (!value || value.trim() === '') return true
try {
JSON.parse(value)
return true
} catch {
return false
}
}
/**
* Validate model mapping format
*/
export function validateModelMapping(value: string): boolean {
if (!value || value.trim() === '') return true
return validateJSON(value)
}
/**
* Parse models string to array
*/
export function parseModels(models: string): string[] {
if (!models) return []
return models
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0)
}
/**
* Parse groups string to array
*/
export function parseGroups(groups: string): string[] {
if (!groups) return []
return groups
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0)
}
/**
* Format models array to string
*/
export function formatModels(models: string[]): string {
return models.join(',')
}
/**
* Format groups array to string
*/
export function formatGroups(groups: string[]): string {
return groups.join(',')
}
@@ -0,0 +1,180 @@
import { CHANNEL_TYPES } from '../constants'
// ============================================================================
// Channel Type Configuration
// ============================================================================
export interface ChannelTypeConfig {
id: number
name: string
icon: string
defaultBaseUrl?: string
requiresOrganization?: boolean
requiresRegion?: boolean
supportedModels?: string[]
hints?: {
baseUrl?: string
key?: string
models?: string
other?: string
}
validation?: {
keyFormat?: RegExp
keyMinLength?: number
}
}
/**
* Configuration for each channel type
*/
export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
1: {
id: 1,
name: CHANNEL_TYPES[1],
icon: 'openai',
defaultBaseUrl: 'https://api.openai.com',
requiresOrganization: true,
hints: {
baseUrl: 'Default: https://api.openai.com',
key: 'Format: sk-...',
models: 'gpt-4,gpt-4-turbo,gpt-3.5-turbo',
},
validation: {
keyFormat: /^sk-/,
keyMinLength: 20,
},
},
3: {
id: 3,
name: CHANNEL_TYPES[3],
icon: 'azure',
requiresRegion: true,
hints: {
baseUrl: 'Azure OpenAI Endpoint',
key: 'Azure API Key',
models: 'Deployment names',
},
},
14: {
id: 14,
name: CHANNEL_TYPES[14],
icon: 'anthropic',
defaultBaseUrl: 'https://api.anthropic.com',
hints: {
key: 'Format: sk-ant-...',
models: 'claude-3-opus,claude-3-sonnet,claude-3-haiku',
},
},
24: {
id: 24,
name: CHANNEL_TYPES[24],
icon: 'google',
hints: {
key: 'Google API Key',
models: 'gemini-pro,gemini-pro-vision',
},
},
41: {
id: 41,
name: CHANNEL_TYPES[41],
icon: 'google',
requiresRegion: true,
hints: {
key: 'Service account JSON or API key',
models: 'gemini-pro,gemini-1.5-pro',
other: 'Region config: {"default": "us-central1"}',
},
},
43: {
id: 43,
name: CHANNEL_TYPES[43],
icon: 'deepseek',
defaultBaseUrl: 'https://api.deepseek.com',
hints: {
key: 'DeepSeek API Key',
models: 'deepseek-chat,deepseek-coder',
},
},
20: {
id: 20,
name: CHANNEL_TYPES[20],
icon: 'openrouter',
defaultBaseUrl: 'https://openrouter.ai/api',
hints: {
key: 'OpenRouter API Key',
models: 'Use model IDs from OpenRouter',
},
},
56: {
id: 56,
name: CHANNEL_TYPES[56],
icon: 'replicate',
defaultBaseUrl: 'https://api.replicate.com',
hints: {
key: 'Replicate API Token',
models: 'Replicate model IDs',
baseUrl: 'Default: https://api.replicate.com',
},
},
}
/**
* Get configuration for a channel type
*/
export function getChannelTypeConfig(type: number): ChannelTypeConfig {
return (
CHANNEL_TYPE_CONFIGS[type] || {
id: type,
name: CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown',
icon: 'openai',
}
)
}
/**
* Check if channel type requires organization field
*/
export function requiresOrganization(type: number): boolean {
return CHANNEL_TYPE_CONFIGS[type]?.requiresOrganization || false
}
/**
* Check if channel type requires region configuration
*/
export function requiresRegion(type: number): boolean {
return CHANNEL_TYPE_CONFIGS[type]?.requiresRegion || false
}
/**
* Get default base URL for channel type
*/
export function getDefaultBaseUrl(type: number): string {
return CHANNEL_TYPE_CONFIGS[type]?.defaultBaseUrl || ''
}
/**
* Get hints for channel type
*/
export function getChannelTypeHints(type: number) {
return CHANNEL_TYPE_CONFIGS[type]?.hints || {}
}
/**
* Validate API key format for channel type
*/
export function validateKeyFormat(type: number, key: string): boolean {
const config = CHANNEL_TYPE_CONFIGS[type]
if (!config?.validation) return true
const { keyFormat, keyMinLength } = config.validation
if (keyMinLength && key.length < keyMinLength) {
return false
}
if (keyFormat && !keyFormat.test(key)) {
return false
}
return true
}
+643
View File
@@ -0,0 +1,643 @@
import { formatCurrencyFromUSD, formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format'
import {
CHANNEL_STATUS_CONFIG,
CHANNEL_TYPES,
MULTI_KEY_STATUS_CONFIG,
RESPONSE_TIME_CONFIG,
RESPONSE_TIME_THRESHOLDS,
TYPE_TO_KEY_PROMPT,
} from '../constants'
import type { Channel, ChannelSettings, ChannelOtherSettings } from '../types'
// ============================================================================
// Channel Type Utilities
// ============================================================================
/**
* Get human-readable channel type label
*/
export function getChannelTypeLabel(type: number): string {
return CHANNEL_TYPES[type as keyof typeof CHANNEL_TYPES] || 'Unknown'
}
/**
* Get channel type icon name for getLobeIcon
* Maps channel types to Lobe icon names using type number (language-independent)
*/
export function getChannelTypeIcon(type: number): string {
const TYPE_TO_ICON: Record<number, string> = {
// OpenAI family
1: 'OpenAI', // OpenAI
6: 'OpenAI', // OpenAIMax
7: 'OpenAI', // OhMyGPT
8: 'OpenAI', // Custom
3: 'Azure', // Azure
// Anthropic
14: 'Claude', // Anthropic
// Google family
24: 'Gemini', // Gemini
11: 'Google', // PaLM
41: 'Gemini', // Vertex AI
// Cloud providers
33: 'Aws', // AWS
39: 'Cloudflare', // Cloudflare
// Chinese providers
15: 'Baidu', // Baidu
46: 'Baidu', // Baidu V2
16: 'Zhipu', // Zhipu
26: 'Zhipu', // Zhipu V4
17: 'Qwen', // Ali
18: 'Spark', // Xunfei
23: 'Hunyuan', // Tencent
19: 'Ai360', // 360
25: 'Moonshot', // Moonshot
31: 'Yi', // LingYiWanWu
35: 'Minimax', // MiniMax
45: 'Volcengine', // VolcEngine
// Other AI providers
4: 'Ollama', // Ollama
27: 'Perplexity', // Perplexity
34: 'Cohere', // Cohere
42: 'Mistral', // Mistral
43: 'DeepSeek', // DeepSeek
48: 'XAI', // xAI
49: 'Coze', // Coze
40: 'SiliconCloud', // SiliconFlow
44: 'OpenAI', // MokaAI
20: 'OpenRouter', // OpenRouter
// Image/Video generation
2: 'Midjourney', // Midjourney
5: 'Midjourney', // MidjourneyPlus
50: 'Kling', // Kling
51: 'Jimeng', // Jimeng
52: 'Vidu', // Vidu
36: 'Suno', // SunoAPI
55: 'OpenAI', // Sora
54: 'Doubao', // DoubaoVideo
56: 'Replicate', // Replicate
// Tools & Platforms
37: 'Dify', // Dify
38: 'Jina', // Jina
22: 'FastGPT', // FastGPT
47: 'Xinference', // Xinference
53: 'OpenAI', // Submodel
// AI Proxy services
10: 'OpenAI', // AI Proxy
21: 'OpenAI', // AI Proxy Library
12: 'OpenAI', // API2GPT
13: 'OpenAI', // AIGC2D
9: 'OpenAI', // AILS
}
return TYPE_TO_ICON[type] || 'OpenAI'
}
// ============================================================================
// Status Utilities
// ============================================================================
/**
* Get status badge configuration
*/
export function getChannelStatusBadge(status: number) {
return (
CHANNEL_STATUS_CONFIG[status as keyof typeof CHANNEL_STATUS_CONFIG] ||
CHANNEL_STATUS_CONFIG[0]
)
}
/**
* Get multi-key status badge configuration
*/
export function getMultiKeyStatusBadge(status: number) {
return (
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] ||
MULTI_KEY_STATUS_CONFIG[1]
)
}
/**
* Check if channel is enabled
*/
export function isChannelEnabled(channel: Channel): boolean {
return channel.status === 1
}
/**
* Check if channel is multi-key
*/
export function isMultiKeyChannel(channel: Channel): boolean {
return channel.channel_info?.is_multi_key || false
}
// ============================================================================
// Key Formatting
// ============================================================================
/**
* Format channel key for display
* Masks the key for security, showing only first and last few characters
*/
export function formatChannelKey(
key: string,
isMultiKey: boolean = false
): string {
if (!key) return ''
if (isMultiKey) {
const keys = key.split('\n').filter((k) => k.trim())
return `${keys.length} keys`
}
if (key.length <= 16) {
// For short keys, mask middle part
return `${key.slice(0, 4)}...${key.slice(-4)}`
}
// For longer keys, show more context
return `${key.slice(0, 8)}...${key.slice(-8)}`
}
/**
* Format key preview for multi-key display
*/
export function formatKeyPreview(key: string, maxLength: number = 10): string {
if (!key) return ''
if (key.length <= maxLength) return key
return `${key.slice(0, maxLength)}...`
}
/**
* Count keys in multi-key string
*/
export function countKeys(key: string): number {
if (!key) return 0
return key.split('\n').filter((k) => k.trim()).length
}
// ============================================================================
// Model & Group Parsing
// ============================================================================
/**
* Parse comma-separated models list
*/
export function parseModelsList(models: string): string[] {
if (!models) return []
return models
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0)
}
/**
* Parse comma-separated groups list.
* Sorts with 'default' group first, then locale-sorted alphabetically.
*/
export function parseGroupsList(groups: string): string[] {
if (!groups) return []
const list = groups
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0)
return list.sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return a.localeCompare(b)
})
}
/**
* Format models array back to string
*/
export function formatModelsString(models: string[]): string {
return models.join(',')
}
/**
* Format groups array back to string
*/
export function formatGroupsString(groups: string[]): string {
return groups.join(',')
}
// ============================================================================
// Settings Parsing
// ============================================================================
/**
* Parse channel settings JSON
*/
export function parseChannelSettings(
settingStr: string | null | undefined
): ChannelSettings {
if (!settingStr) return {}
try {
return JSON.parse(settingStr) as ChannelSettings
} catch {
return {}
}
}
/**
* Parse channel other settings JSON
*/
export function parseChannelOtherSettings(
settingsStr: string | null | undefined
): ChannelOtherSettings {
if (!settingsStr || settingsStr === '{}') return {}
try {
return JSON.parse(settingsStr) as ChannelOtherSettings
} catch {
return {}
}
}
/**
* Validate JSON string
*/
export function validateChannelSettings(settings: string): boolean {
if (!settings || settings.trim() === '') return true
try {
JSON.parse(settings)
return true
} catch {
return false
}
}
// ============================================================================
// Balance Formatting
// ============================================================================
/**
* Format balance with currency symbol
*/
export function formatBalance(balance: number | null | undefined): string {
if (balance == null || Number.isNaN(balance)) return '-'
return formatCurrencyFromUSD(balance, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: false,
})
}
/**
* Get balance status color
*/
export function getBalanceVariant(
balance: number
): 'success' | 'warning' | 'danger' | 'neutral' {
if (balance === 0) return 'neutral'
if (balance < 1) return 'danger'
if (balance < 10) return 'warning'
return 'success'
}
// ============================================================================
// Response Time Utilities
// ============================================================================
/** Optional i18n: (key, options) => string, e.g. useTranslation().t */
type TFunction = (key: string, options?: { value?: number | string }) => string
/**
* Format response time in milliseconds to human-readable.
* Pass `t` from useTranslation() for i18n (e.g. "Not tested", "{{value}}ms", "{{value}}s").
*/
export function formatResponseTime(timeMs: number, t?: TFunction): string {
if (timeMs === 0) return t ? t('Not tested') : 'Not tested'
if (timeMs < 1000)
return t ? t('{{value}}ms', { value: timeMs }) : `${timeMs}ms`
return t
? t('{{value}}s', { value: (timeMs / 1000).toFixed(2) })
: `${(timeMs / 1000).toFixed(2)}s`
}
/**
* Get response time performance rating
*/
export function getResponseTimeConfig(timeMs: number) {
if (timeMs === 0) return RESPONSE_TIME_CONFIG.UNKNOWN
if (timeMs <= RESPONSE_TIME_THRESHOLDS.EXCELLENT)
return RESPONSE_TIME_CONFIG.EXCELLENT
if (timeMs <= RESPONSE_TIME_THRESHOLDS.GOOD) return RESPONSE_TIME_CONFIG.GOOD
if (timeMs <= RESPONSE_TIME_THRESHOLDS.FAIR) return RESPONSE_TIME_CONFIG.FAIR
if (timeMs <= RESPONSE_TIME_THRESHOLDS.POOR) return RESPONSE_TIME_CONFIG.POOR
return RESPONSE_TIME_CONFIG.POOR
}
// ============================================================================
// Time Formatting
// ============================================================================
/**
* Format Unix timestamp to relative time
* e.g., "2 hours ago", "3 days ago"
*/
export function formatRelativeTime(timestamp: number): string {
if (!timestamp || timestamp === 0) return 'Never'
try {
return dayjs(timestamp * 1000).fromNow()
} catch {
return 'Unknown'
}
}
/**
* Format Unix timestamp to date string
*/
export function formatTimestamp(timestamp: number): string {
if (!timestamp || timestamp === 0) return 'N/A'
try {
return formatTimestampToDate(timestamp)
} catch {
return 'Invalid date'
}
}
// ============================================================================
// Quota Formatting
// ============================================================================
/** Format quota units using the global currency display configuration. */
export function formatQuota(quota: number): string {
return formatQuotaWithCurrency(quota, {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
})
}
// ============================================================================
// Priority & Weight Utilities
// ============================================================================
/**
* Get priority display value
*/
export function getPriorityDisplay(
priority: number | null | undefined
): string {
if (priority === null || priority === undefined) return '0'
return String(priority)
}
/**
* Get weight display value
*/
export function getWeightDisplay(weight: number | null | undefined): string {
if (weight === null || weight === undefined) return '0'
return String(weight)
}
// ============================================================================
// Validation Utilities
// ============================================================================
/**
* Validate channel name
*/
export function validateChannelName(name: string): boolean {
return name.trim().length > 0
}
/**
* Validate API key format
*/
export function validateApiKey(key: string): boolean {
return key.trim().length > 0
}
/**
* Validate models list
*/
export function validateModels(models: string): boolean {
return parseModelsList(models).length > 0
}
/**
* Validate groups list
*/
export function validateGroups(groups: string): boolean {
return parseGroupsList(groups).length > 0
}
/**
* Check if channel needs attention (low balance, auto-disabled, etc.)
*/
export function channelNeedsAttention(channel: Channel): boolean {
// Auto-disabled
if (channel.status === 3) return true
// Low balance (less than $1)
if (channel.balance > 0 && channel.balance < 1) return true
// Multi-key channel with all keys disabled
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return true
}
return false
}
/**
* Get attention reason for channel
*/
export function getAttentionReason(channel: Channel): string | null {
if (channel.status === 3) return 'Auto-disabled'
if (channel.balance > 0 && channel.balance < 1) return 'Low balance'
if (
channel.channel_info?.is_multi_key &&
channel.channel_info.multi_key_status_list &&
Object.keys(channel.channel_info.multi_key_status_list).length >=
channel.channel_info.multi_key_size
) {
return 'All keys disabled'
}
return null
}
// ============================================================================
// Tag Aggregation Utilities
// ============================================================================
/**
* Tag row type (extends Channel with children)
*/
export type TagRow = Channel & {
children: Channel[]
}
/**
* Type guard to check whether a row is a tag aggregate row
*/
export function isTagAggregateRow(row: Channel | TagRow): row is TagRow {
return Array.isArray((row as TagRow).children)
}
/**
* Aggregate channels by tag for tag mode display
* Converts flat array into tree structure grouped by tag
*/
export function aggregateChannelsByTag(
channels: Channel[]
): (Channel | TagRow)[] {
const tagMap = new Map<string, TagRow>()
const result: (Channel | TagRow)[] = []
for (const channel of channels) {
const tag = channel.tag || ''
if (!tagMap.has(tag)) {
// Create tag aggregate row
const tagRow = {
...channel,
key: tag,
id: tag as unknown as number,
tag: tag,
name: tag,
type: 0,
status: undefined as unknown as number,
group: '',
used_quota: 0,
response_time: 0,
priority: -1 as unknown as number | null,
weight: -1 as unknown as number | null,
balance: 0,
test_time: 0,
created_time: 0,
balance_updated_time: 0,
models: '',
children: [],
} as TagRow
tagMap.set(tag, tagRow)
result.push(tagRow)
}
const tagRow = tagMap.get(tag)!
// Add to children
tagRow.children.push(channel)
const childCount = tagRow.children.length
// Aggregate used_quota (sum)
tagRow.used_quota += channel.used_quota
// Aggregate response_time (average)
tagRow.response_time =
(tagRow.response_time * (childCount - 1) + channel.response_time) /
childCount
// Aggregate priority (same value or null if different)
if (tagRow.priority === -1) {
tagRow.priority = channel.priority
} else if (tagRow.priority !== channel.priority) {
tagRow.priority = null
}
// Aggregate weight (same value or null if different)
if (tagRow.weight === -1) {
tagRow.weight = channel.weight
} else if (tagRow.weight !== channel.weight) {
tagRow.weight = null
}
// Aggregate group (concatenate and deduplicate)
if (tagRow.group === '') {
tagRow.group = channel.group
} else {
const existingGroups = new Set(tagRow.group.split(',').filter(Boolean))
const newGroups = channel.group.split(',').filter(Boolean)
newGroups.forEach((g) => {
if (!existingGroups.has(g)) {
tagRow.group += ',' + g
}
})
}
// Aggregate status (enabled if any child is enabled)
if (channel.status === 1) {
tagRow.status = 1
} else if (tagRow.status === undefined) {
tagRow.status = channel.status
}
}
return result
}
// ============================================================================
// Key Management Utilities
// ============================================================================
/**
* Deduplicate keys from a multiline string
* @param keysText - Text with one key per line
* @returns Object with deduplicated keys and statistics
*/
export function deduplicateKeys(keysText: string): {
deduplicatedText: string
beforeCount: number
afterCount: number
removedCount: number
} {
if (!keysText || keysText.trim() === '') {
return {
deduplicatedText: '',
beforeCount: 0,
afterCount: 0,
removedCount: 0,
}
}
// Split by lines
const keyLines = keysText.split('\n')
const beforeCount = keyLines.length
// Use Set for deduplication, maintaining order
const keySet = new Set<string>()
const deduplicatedKeys: string[] = []
keyLines.forEach((line) => {
const trimmedLine = line.trim()
if (trimmedLine && !keySet.has(trimmedLine)) {
keySet.add(trimmedLine)
deduplicatedKeys.push(trimmedLine)
}
})
const afterCount = deduplicatedKeys.length
const deduplicatedText = deduplicatedKeys.join('\n')
return {
deduplicatedText,
beforeCount,
afterCount,
removedCount: beforeCount - afterCount,
}
}
/**
* Get key prompt based on channel type
*/
export function getKeyPromptForType(type: number): string {
return TYPE_TO_KEY_PROMPT[type] || 'Enter API key for this channel'
}
+7
View File
@@ -0,0 +1,7 @@
// Re-export all library functions
export * from './channel-actions'
export * from './channel-form'
export * from './channel-type-config'
export * from './channel-utils'
export * from './multi-key-utils'
export * from './model-mapping-validation'
@@ -0,0 +1,229 @@
// ============================================================================
// Model Mapping Validation Utilities
// ============================================================================
/**
* Parse models string to array
*/
export function parseModelsString(modelsStr: string): string[] {
return modelsStr
? modelsStr
.split(',')
.map((m) => m.trim())
.filter(Boolean)
: []
}
/**
* Format models array to string
*/
export function formatModelsArray(models: string[]): string {
return Array.from(new Set(models)).join(',')
}
/**
* Normalize model name
*/
export function normalizeModelName(model: string): string {
return typeof model === 'string' ? model.trim() : ''
}
/**
* Extract source keys from model_mapping JSON
* (the keys of the mapping object — models being remapped FROM)
*/
export function extractMappingSourceModels(modelMapping: string): string[] {
if (typeof modelMapping !== 'string') return []
const trimmed = modelMapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const keys = Object.keys(parsed)
.map((key) => key.trim())
.filter(Boolean)
return Array.from(new Set(keys))
} catch {
return []
}
}
/**
* Extract redirect models from model_mapping JSON
*/
export function extractRedirectModels(modelMapping: string): string[] {
const mapping = modelMapping
if (typeof mapping !== 'string') return []
const trimmed = mapping.trim()
if (!trimmed) return []
try {
const parsed = JSON.parse(trimmed)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return []
}
const values = Object.values(parsed)
.map((value) => (typeof value === 'string' ? value.trim() : undefined))
.filter((value): value is string => Boolean(value))
return Array.from(new Set(values))
} catch {
return []
}
}
/**
* Check if model configuration has changed
*/
export function hasModelConfigChanged(
currentModels: string[],
currentModelMapping: string,
initialModels: string[],
initialModelMapping: string
): boolean {
// Always return true if not editing (new channel)
if (initialModels.length === 0 && !initialModelMapping) {
return true
}
// Check if models array changed
if (currentModels.length !== initialModels.length) {
return true
}
for (let i = 0; i < currentModels.length; i++) {
if (currentModels[i] !== initialModels[i]) {
return true
}
}
// Check if model_mapping changed
const normalizedCurrent = (currentModelMapping || '').trim()
const normalizedInitial = (initialModelMapping || '').trim()
return normalizedCurrent !== normalizedInitial
}
/**
* Find models in model_mapping that are missing from the models list
*/
export function findMissingModelsInMapping(
modelMapping: string,
currentModels: string[]
): string[] {
if (!modelMapping || modelMapping.trim() === '') {
return []
}
let parsedMapping: Record<string, unknown>
try {
parsedMapping = JSON.parse(modelMapping)
if (
!parsedMapping ||
typeof parsedMapping !== 'object' ||
Array.isArray(parsedMapping)
) {
return []
}
} catch {
return []
}
const modelSet = new Set(currentModels.map((m) => normalizeModelName(m)))
const missingModels = Object.keys(parsedMapping)
.map((key) => normalizeModelName(key))
.filter((key) => key && !modelSet.has(key))
return Array.from(new Set(missingModels))
}
/**
* Validate model mapping JSON format
*/
export function validateModelMappingJson(modelMapping: string): {
valid: boolean
error?: string
} {
if (!modelMapping || modelMapping.trim() === '') {
return { valid: true }
}
try {
const parsed = JSON.parse(modelMapping)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {
valid: false,
error: 'Model mapping must be a valid JSON object',
}
}
return { valid: true }
} catch {
return {
valid: false,
error: 'Model mapping must be valid JSON format',
}
}
}
/**
* Get redirect models that are also in the models list
* (These should be removed from models list to keep /v1/models clean)
*/
export function findExposedTargetModels(
modelMapping: string,
currentModels: string[]
): string[] {
const redirectModels = extractRedirectModels(modelMapping)
if (redirectModels.length === 0) return []
const normalizedModels = currentModels.map((m) => normalizeModelName(m))
const modelSet = new Set(normalizedModels)
return redirectModels.filter((model) =>
modelSet.has(normalizeModelName(model))
)
}
/**
* Categorize models into different sets for UI display
*/
export function categorizeModelsWithRedirect(
currentModels: string[],
redirectModels: string[]
): {
normalizedCurrentModels: Set<string>
normalizedRedirectModels: Set<string>
classificationSet: Set<string>
redirectOnlySet: Set<string>
} {
const normalizedCurrentModels = new Set(
currentModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const normalizedRedirectModels = new Set(
redirectModels.map((m) => normalizeModelName(m)).filter(Boolean)
)
const classificationSet = new Set([
...normalizedCurrentModels,
...normalizedRedirectModels,
])
const redirectOnlySet = new Set(
Array.from(normalizedRedirectModels).filter(
(m) => !normalizedCurrentModels.has(m)
)
)
return {
normalizedCurrentModels,
normalizedRedirectModels,
classificationSet,
redirectOnlySet,
}
}
@@ -0,0 +1,57 @@
import {
MULTI_KEY_STATUS_CONFIG,
MULTI_KEY_CONFIRM_MESSAGES,
} from '../constants'
import type { MultiKeyConfirmAction } from '../types'
/**
* Get status badge configuration for multi-key status
*/
export function getMultiKeyStatusConfig(status: number) {
return (
MULTI_KEY_STATUS_CONFIG[status as keyof typeof MULTI_KEY_STATUS_CONFIG] || {
variant: 'neutral' as const,
label: 'Unknown',
}
)
}
/**
* Get confirmation message for multi-key action
*/
export function getMultiKeyConfirmMessage(
action: MultiKeyConfirmAction | null
): string {
if (!action) return ''
switch (action.type) {
case 'delete':
return MULTI_KEY_CONFIRM_MESSAGES.DELETE
case 'enable':
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE
case 'disable':
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE
case 'enable-all':
return MULTI_KEY_CONFIRM_MESSAGES.ENABLE_ALL
case 'disable-all':
return MULTI_KEY_CONFIRM_MESSAGES.DISABLE_ALL
case 'delete-disabled':
return MULTI_KEY_CONFIRM_MESSAGES.DELETE_DISABLED
default:
return ''
}
}
/**
* Check if action is destructive
*/
export function isDestructiveAction(
action: MultiKeyConfirmAction | null
): boolean {
if (!action) return false
return (
action.type === 'delete' ||
action.type === 'delete-disabled' ||
action.type === 'disable-all'
)
}
+143
View File
@@ -0,0 +1,143 @@
import type { Channel } from '../types'
export type PullProgress = {
status?: string
completed?: number
total?: number
// backend may include extra fields
[k: string]: unknown
}
export type OllamaModel = {
id: string
owned_by?: string
size?: number
digest?: string
modified_at?: string
details?: unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function getNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function parseMaybeJSON(value: unknown) {
if (!value) return null
if (typeof value === 'object') return value
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return null
}
}
return null
}
/**
* Resolve Ollama base URL from channel fields (supports legacy/alternate fields).
*/
export function resolveOllamaBaseUrl(channel: Channel | null) {
if (!channel) return ''
const direct =
typeof channel.base_url === 'string' ? channel.base_url.trim() : ''
if (direct) return direct
const alt =
typeof (channel as unknown as { ollama_base_url?: unknown })
?.ollama_base_url === 'string'
? String(
(channel as unknown as { ollama_base_url?: string }).ollama_base_url
).trim()
: ''
if (alt) return alt
const parsed = parseMaybeJSON(channel.other_info)
if (isRecord(parsed)) {
const baseUrl = getString(parsed.base_url)?.trim()
if (baseUrl) return baseUrl
const publicUrl = getString(parsed.public_url)?.trim()
if (publicUrl) return publicUrl
const apiUrl = getString(parsed.api_url)?.trim()
if (apiUrl) return apiUrl
}
return ''
}
export function normalizeOllamaModels(items: unknown): OllamaModel[] {
if (!Array.isArray(items)) return []
return items
.map((item) => {
if (!item) return null
if (typeof item === 'string') {
return { id: item, owned_by: 'ollama' } satisfies OllamaModel
}
if (isRecord(item)) {
const candidateId =
getString(item.id) ||
getString(item.ID) ||
getString(item.name) ||
getString(item.model) ||
getString(item.Model)
if (!candidateId) return null
const metadata = item.metadata ?? item.Metadata
const normalized: OllamaModel = {
...item,
id: candidateId,
owned_by:
getString(item.owned_by) || getString(item.ownedBy) || 'ollama',
}
const itemSize = getNumber(item.size)
if (typeof itemSize === 'number' && !normalized.size) {
normalized.size = itemSize
}
if (isRecord(metadata)) {
const metaSize = getNumber(metadata.size)
if (typeof metaSize === 'number' && !normalized.size) {
normalized.size = metaSize
}
const metaDigest = getString(metadata.digest)
if (!normalized.digest && metaDigest) {
normalized.digest = metaDigest
}
const metaModifiedAt = getString(metadata.modified_at)
if (!normalized.modified_at && metaModifiedAt) {
normalized.modified_at = metaModifiedAt
}
if (metadata.details && !normalized.details) {
normalized.details = metadata.details
}
}
return normalized
}
return null
})
.filter(Boolean) as OllamaModel[]
}
export function formatBytes(bytes?: number) {
if (typeof bytes !== 'number' || Number.isNaN(bytes)) return '-'
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
if (mb < 1024) return `${mb.toFixed(1)} MB`
const gb = mb / 1024
return `${gb.toFixed(2)} GB`
}
@@ -0,0 +1,85 @@
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524])
function parseStatusCodeKey(rawKey: string): number | null {
const normalized = rawKey.trim()
if (!/^[1-5]\d{2}$/.test(normalized)) return null
return Number.parseInt(normalized, 10)
}
function parseStatusCodeMappingTarget(rawValue: unknown): number | null {
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
return rawValue >= 100 && rawValue <= 599 ? rawValue : null
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim()
if (!/^[1-5]\d{2}$/.test(normalized)) return null
const code = Number.parseInt(normalized, 10)
return code >= 100 && code <= 599 ? code : null
}
return null
}
export function collectInvalidStatusCodeEntries(
statusCodeMappingStr: string
): string[] {
if (!statusCodeMappingStr?.trim()) return []
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(statusCodeMappingStr)
} catch {
return []
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
const invalid: string[] = []
for (const [rawKey, rawValue] of Object.entries(parsed)) {
const fromCode = parseStatusCodeKey(rawKey)
const toCode = parseStatusCodeMappingTarget(rawValue)
if (fromCode === null || toCode === null) {
invalid.push(`${rawKey}${rawValue}`)
}
}
return invalid
}
export function collectDisallowedStatusCodeRedirects(
statusCodeMappingStr: string
): string[] {
if (!statusCodeMappingStr?.trim()) return []
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(statusCodeMappingStr)
} catch {
return []
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
const riskyMappings: string[] = []
for (const [rawFrom, rawTo] of Object.entries(parsed)) {
const fromCode = parseStatusCodeKey(rawFrom)
const toCode = parseStatusCodeMappingTarget(rawTo)
if (fromCode === null || toCode === null) continue
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) continue
if (fromCode === toCode) continue
riskyMappings.push(`${fromCode} -> ${toCode}`)
}
return [...new Set(riskyMappings)].sort()
}
export function collectNewDisallowedStatusCodeRedirects(
originalStr: string,
currentStr: string
): string[] {
const currentRisky = collectDisallowedStatusCodeRedirects(currentStr)
if (currentRisky.length === 0) return []
const originalRiskySet = new Set(
collectDisallowedStatusCodeRedirects(originalStr)
)
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping))
}
@@ -0,0 +1,38 @@
export function normalizeModelList(models: unknown[] = []): string[] {
return Array.from(
new Set(
(models || []).map((model) => String(model || '').trim()).filter(Boolean)
)
)
}
export function parseUpstreamUpdateMeta(settings: unknown): {
enabled: boolean
pendingAddModels: string[]
pendingRemoveModels: string[]
} {
let parsed: Record<string, unknown> | null = null
if (settings && typeof settings === 'object' && !Array.isArray(settings)) {
parsed = settings as Record<string, unknown>
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings)
} catch {
parsed = null
}
}
if (!parsed || typeof parsed !== 'object') {
return { enabled: false, pendingAddModels: [], pendingRemoveModels: [] }
}
return {
enabled: parsed.upstream_model_update_check_enabled === true,
pendingAddModels: normalizeModelList(
(parsed.upstream_model_update_last_detected_models as unknown[]) || []
),
pendingRemoveModels: normalizeModelList(
(parsed.upstream_model_update_last_removed_models as unknown[]) || []
),
}
}
+295
View File
@@ -0,0 +1,295 @@
import { z } from 'zod'
// ============================================================================
// Channel Schema & Types
// ============================================================================
export const channelInfoSchema = z.object({
is_multi_key: z.boolean().default(false),
multi_key_size: z.number().default(0),
multi_key_status_list: z.record(z.string(), z.number()).optional(),
multi_key_disabled_reason: z.record(z.string(), z.string()).optional(),
multi_key_disabled_time: z.record(z.string(), z.number()).optional(),
multi_key_polling_index: z.number().default(0),
multi_key_mode: z.enum(['random', 'polling']).default('random'),
})
export type ChannelInfo = z.infer<typeof channelInfoSchema>
export const channelSchema = z.object({
id: z.number(),
type: z.number(),
key: z.string(),
openai_organization: z.string().nullish(),
test_model: z.string().nullish(),
status: z.number(), // 1: enabled, 0: manual disabled, 2: auto disabled
name: z.string(),
weight: z.number().nullish(),
created_time: z.number(),
test_time: z.number(),
response_time: z.number(), // in milliseconds
base_url: z.string().nullish(),
other: z.string().default(''),
balance: z.number().default(0), // in USD
balance_updated_time: z.number(),
models: z.string().default(''),
group: z.string().default('default'),
used_quota: z.number().default(0),
model_mapping: z.string().nullish(),
status_code_mapping: z.string().nullish(),
priority: z.number().nullish(),
auto_ban: z.number().nullish(),
other_info: z.string().default(''),
tag: z.string().nullish(),
setting: z.string().nullish(),
param_override: z.string().nullish(),
header_override: z.string().nullish(),
remark: z.string().default(''),
max_input_tokens: z.number().default(0),
channel_info: channelInfoSchema.default({
is_multi_key: false,
multi_key_size: 0,
multi_key_polling_index: 0,
multi_key_mode: 'random',
}),
settings: z.string().default('{}'), // other_settings JSON
})
export type Channel = z.infer<typeof channelSchema>
// ============================================================================
// Channel Settings Types
// ============================================================================
export interface ChannelSettings {
force_format?: boolean
thinking_to_content?: boolean
proxy?: string
pass_through_body_enabled?: boolean
system_prompt?: string
system_prompt_override?: boolean
}
export interface ChannelOtherSettings {
azure_responses_version?: string
vertex_key_type?: 'json' | 'api_key'
openrouter_enterprise?: boolean
aws_key_type?: 'ak_sk' | 'api_key'
allow_service_tier?: boolean
disable_store?: boolean
allow_safety_identifier?: boolean
}
// ============================================================================
// API Response Types
// ============================================================================
export interface GetChannelsResponse {
success: boolean
message?: string
data?: {
items: Channel[]
total: number
page: number
page_size: number
type_counts?: Record<string, number>
}
}
export interface SearchChannelsResponse {
success: boolean
message?: string
data?: {
items: Channel[]
total: number
type_counts?: Record<string, number>
}
}
export interface GetChannelResponse {
success: boolean
message?: string
data?: Channel
}
export interface ChannelTestResponse {
success: boolean
message?: string
error_code?: string
data?: {
response_time?: number
error?: string
}
}
export interface ChannelBalanceResponse {
success: boolean
message?: string
balance?: number
currency?: string
}
export interface FetchModelsResponse {
success: boolean
message?: string
data?: string[]
}
export interface CopyChannelResponse {
success: boolean
message?: string
data?: {
id: number
}
}
// ============================================================================
// Multi-Key Management Types
// ============================================================================
export interface KeyStatus {
index: number
status: number // 1: enabled, 2: manual disabled, 3: auto disabled
disabled_time?: number
reason?: string
key_preview?: string
}
export type MultiKeyConfirmAction = {
type:
| 'enable'
| 'disable'
| 'delete'
| 'enable-all'
| 'disable-all'
| 'delete-disabled'
keyIndex?: number
}
export interface MultiKeyStatusResponse {
success: boolean
message?: string
data?: {
keys: KeyStatus[]
total: number
page: number
page_size: number
total_pages: number
enabled_count: number
manual_disabled_count: number
auto_disabled_count: number
}
}
// ============================================================================
// API Request Parameters
// ============================================================================
export interface GetChannelsParams {
p?: number
page_size?: number
status?: string // 'enabled', 'disabled', or empty for all
type?: number
group?: string
id_sort?: boolean
tag_mode?: boolean
}
export interface SearchChannelsParams {
keyword?: string
group?: string
model?: string
status?: string
type?: number
id_sort?: boolean
tag_mode?: boolean
p?: number
page_size?: number
}
export interface ChannelTestParams {
test_model?: string
}
export interface CopyChannelParams {
suffix?: string
reset_balance?: boolean
}
export interface MultiKeyManageParams {
channel_id: number
action:
| 'get_key_status'
| 'disable_key'
| 'enable_key'
| 'enable_all_keys'
| 'disable_all_keys'
| 'delete_key'
| 'delete_disabled_keys'
key_index?: number
page?: number
page_size?: number
status?: number // 1=enabled, 2=manual_disabled, 3=auto_disabled
}
export interface BatchDeleteParams {
ids: number[]
}
export interface BatchSetTagParams {
ids: number[]
tag: string | null
}
export interface TagOperationParams {
tag: string
new_tag?: string
priority?: number
weight?: number
model_mapping?: string
models?: string
groups?: string
}
// ============================================================================
// Form Data Types
// ============================================================================
export interface ChannelFormData {
name: string
type: number
base_url: string
key: string
openai_organization?: string
models: string
group: string
model_mapping?: string
priority?: number
weight?: number
test_model?: string
auto_ban?: number
status: number
status_code_mapping?: string
tag?: string
remark?: string
setting?: string
param_override?: string
header_override?: string
settings?: string
other?: string
// Multi-key specific
multi_key_mode?: 'single' | 'batch' | 'multi_to_single'
multi_key_type?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
}
// ============================================================================
// Add Channel Request (special structure)
// ============================================================================
export interface AddChannelRequest {
mode: 'single' | 'batch' | 'multi_to_single'
multi_key_mode?: 'random' | 'polling'
batch_add_set_key_prefix_2_name?: boolean
channel: Partial<Channel>
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
/**
* Get the currently active API key for chat links
*/
export function useActiveChatKey(enabled: boolean) {
return useQuery({
queryKey: ['chat-active-key'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
}
const items = result.data?.items ?? []
const active = items.find(
(item) => item.status === API_KEY_STATUS.ENABLED
)
if (!active) {
throw new Error(
'No enabled API keys found. Create or enable one first.'
)
}
return active.key
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { useStatus } from '@/hooks/use-status'
import type { SystemStatus } from '@/features/auth/types'
import {
type ChatPreset,
parseChatConfig,
type RawChatConfig,
} from '../lib/chat-links'
function getStoredStatusChats(): RawChatConfig {
if (typeof window === 'undefined') return undefined
try {
const raw = window.localStorage.getItem('status')
if (!raw) return undefined
const parsed = JSON.parse(raw)
return parsed?.chats ?? parsed?.Chats
} catch {
return undefined
}
}
function extractServerAddress(status: SystemStatus | null) {
const fromStatus =
(status?.server_address as string | undefined) ??
(status?.serverAddress as string | undefined) ??
status?.data?.server_address ??
(status?.data as Record<string, unknown> | undefined)?.serverAddress
if (fromStatus && typeof fromStatus === 'string') {
return fromStatus
}
if (typeof window !== 'undefined') {
return window.location.origin
}
return ''
}
function extractChats(status: SystemStatus | null): RawChatConfig {
const raw =
status?.Chats ?? status?.chats ?? status?.data?.Chats ?? status?.data?.chats
return (raw as RawChatConfig) ?? getStoredStatusChats()
}
export function useChatPresets(): {
chatPresets: ChatPreset[]
serverAddress: string
} {
const { status } = useStatus()
const serverAddress = useMemo(() => extractServerAddress(status), [status])
const chatPresets = useMemo(() => {
const raw = extractChats(status)
return parseChatConfig(raw)
}, [status])
return {
chatPresets,
serverAddress,
}
}
+166
View File
@@ -0,0 +1,166 @@
import { API_KEY_STATUS } from '@/features/keys/constants'
export type ChatLinkType = 'web' | 'custom-protocol' | 'fluent'
export type ChatPreset = {
id: string
name: string
url: string
type: ChatLinkType
}
export type RawChatConfig =
| string
| Record<string, unknown>
| Array<Record<string, unknown>>
| null
| undefined
export type ResolveChatUrlParams = {
template: string
apiKey?: string
serverAddress: string
}
export type ActiveApiKey = {
key: string
status: number
}
const HTTP_REGEX = /^https?:\/\//i
function toBase64(value: string) {
if (typeof window !== 'undefined' && typeof window.btoa === 'function') {
return window.btoa(value)
}
type BufferConstructorLike = {
from(data: string, encoding: string): { toString(encoding: string): string }
}
const globalObj =
typeof globalThis !== 'undefined'
? (globalThis as Record<string, unknown>)
: undefined
const bufferCtor = globalObj?.Buffer
if (
typeof bufferCtor === 'function' &&
typeof (bufferCtor as unknown as BufferConstructorLike).from === 'function'
) {
return (bufferCtor as unknown as BufferConstructorLike)
.from(value, 'utf-8')
.toString('base64')
}
return ''
}
export function detectChatLinkType(url: string): ChatLinkType {
if (HTTP_REGEX.test(url)) {
return 'web'
}
if (url.toLowerCase().startsWith('fluent')) {
return 'fluent'
}
return 'custom-protocol'
}
export function parseChatConfig(raw: RawChatConfig): ChatPreset[] {
let parsed: unknown = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return []
}
}
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((entry, index) => {
if (
!entry ||
typeof entry !== 'object' ||
Array.isArray(entry) ||
Object.keys(entry).length !== 1
) {
return null
}
const [name, value] = Object.entries(entry)[0]
if (typeof value !== 'string' || typeof name !== 'string') {
return null
}
const url = value.trim()
if (!url) {
return null
}
return {
id: String(index),
name,
url,
type: detectChatLinkType(url),
} satisfies ChatPreset
})
.filter((item): item is ChatPreset => item !== null)
}
function replaceToken(source: string, token: string, value: string) {
return source.split(token).join(value)
}
export function resolveChatUrl({
template,
apiKey,
serverAddress,
}: ResolveChatUrlParams): string {
let url = template
const safeServerAddress = serverAddress || ''
const safeApiKey = apiKey || ''
if (url.includes('{cherryConfig}')) {
const payload = {
id: 'new-api',
baseUrl: safeServerAddress,
apiKey: safeApiKey,
}
const encoded = encodeURIComponent(toBase64(JSON.stringify(payload)))
return replaceToken(url, '{cherryConfig}', encoded)
}
if (url.includes('{aionuiConfig}')) {
const payload = {
platform: 'new-api',
baseUrl: safeServerAddress,
apiKey: safeApiKey,
}
const encoded = encodeURIComponent(toBase64(JSON.stringify(payload)))
return replaceToken(url, '{aionuiConfig}', encoded)
}
if (safeServerAddress) {
const encodedAddress = encodeURIComponent(safeServerAddress)
url = replaceToken(url, '{address}', encodedAddress)
}
if (safeApiKey) {
url = replaceToken(url, '{key}', safeApiKey)
}
return url
}
export function getFirstActiveKey(
keys: ActiveApiKey[] | undefined
): ActiveApiKey | undefined {
if (!Array.isArray(keys)) return undefined
return keys.find((item) => item.status === API_KEY_STATUS.ENABLED)
}
+24
View File
@@ -0,0 +1,24 @@
export function sendToFluent(apiKey: string, serverAddress?: string): boolean {
if (typeof window === 'undefined') {
return false
}
const container = document.getElementById('fluent-new-api-container')
if (!container) {
return false
}
const payload = {
id: 'new-api',
baseUrl: serverAddress || window.location.origin,
apiKey: `sk-${apiKey}`,
}
container.dispatchEvent(
new CustomEvent('fluent:prefill', {
detail: payload,
})
)
return true
}
+49
View File
@@ -0,0 +1,49 @@
import { api } from '@/lib/api'
import type { QuotaDataItem, UptimeGroupResult } from './types'
// ============================================================================
// Dashboard APIs
// ============================================================================
// ----------------------------------------------------------------------------
// Quota & Usage Data
// ----------------------------------------------------------------------------
// Get user quota data within a time range
// Admin users can specify 'username' to view other users' data
export async function getUserQuotaDates(params: {
start_timestamp: number
end_timestamp: number
default_time?: string
username?: string
}) {
const endpoint = params.username ? '/api/data' : '/api/data/self'
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
endpoint,
{ params }
)
return res.data
}
// ----------------------------------------------------------------------------
// System Monitoring
// ----------------------------------------------------------------------------
export async function getUserQuotaDataByUsers(params: {
start_timestamp: number
end_timestamp: number
}) {
const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>(
'/api/data/users',
{ params }
)
return res.data
}
// Get uptime monitoring status for all services
export async function getUptimeStatus() {
const res = await api.get<{ success: boolean; data: UptimeGroupResult[] }>(
'/api/uptime/status'
)
return res.data
}
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { formatNumber, formatQuota } from '@/lib/format'
import { computeTimeRange } from '@/lib/time'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuotaDates } from '@/features/dashboard/api'
import { useModelStatCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
import {
buildQueryParams,
calculateDashboardStats,
getDefaultDays,
} from '@/features/dashboard/lib'
import type {
QuotaDataItem,
DashboardFilters,
} from '@/features/dashboard/types'
interface LogStatCardsProps {
filters?: DashboardFilters
onDataUpdate?: (data: QuotaDataItem[], loading: boolean) => void
}
export function LogStatCards(props: LogStatCardsProps) {
const statCardsConfig = useModelStatCardsConfig()
const [stats, setStats] = useState<{
totalQuota: number
totalCount: number
totalTokens: number
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [timeRangeMinutes, setTimeRangeMinutes] = useState(0)
const { filters, onDataUpdate } = props
useEffect(() => {
const abortController = new AbortController()
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true)
setError(false)
onDataUpdate?.([], true)
const timeRange = computeTimeRange(
getDefaultDays(filters?.time_granularity),
filters?.start_timestamp,
filters?.end_timestamp
)
const timeDiff = (timeRange.end_timestamp - timeRange.start_timestamp) / 60
setTimeRangeMinutes(timeDiff)
getUserQuotaDates(buildQueryParams(timeRange, filters))
.then((res) => {
if (abortController.signal.aborted) return
const data = res?.data || []
setStats(calculateDashboardStats(data))
onDataUpdate?.(data, false)
})
.catch(() => {
if (abortController.signal.aborted) return
setStats(null)
setError(true)
onDataUpdate?.([], false)
})
.finally(() => {
if (!abortController.signal.aborted) {
setLoading(false)
}
})
return () => {
abortController.abort()
}
}, [filters, onDataUpdate])
const adaptedStats = {
rpm: stats?.totalCount ?? 0,
quota: stats?.totalQuota ?? 0,
tpm: stats?.totalTokens ?? 0,
}
const items = statCardsConfig.map((config) => ({
title: config.title,
value:
config.key === 'quota'
? formatQuota(config.getValue(adaptedStats, timeRangeMinutes))
: formatNumber(config.getValue(adaptedStats, timeRangeMinutes)),
desc: config.description,
icon: config.icon,
}))
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
{items.map((it) => {
const Icon = it.icon
return (
<div key={it.title} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{it.title}
</div>
</div>
{loading ? (
<div className='mt-2 space-y-1.5'>
<Skeleton className='h-7 w-20' />
<Skeleton className='h-3.5 w-28' />
</div>
) : error ? (
<>
<div className='text-muted-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
--
</div>
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
) : (
<>
<div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight tabular-nums'>
{it.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{it.desc}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { VChart } from '@visactor/react-vchart'
import { PieChart as PieChartIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { DEFAULT_TIME_GRANULARITY } from '@/features/dashboard/constants'
import { processChartData } from '@/features/dashboard/lib'
import type {
ProcessedChartData,
QuotaDataItem,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type ChartTab = '1' | '2' | '3' | '4'
const CHART_TABS: {
value: ChartTab
labelKey: string
specKey: keyof ProcessedChartData
}[] = [
{ value: '1', labelKey: 'Quota Distribution', specKey: 'spec_line' },
{ value: '2', labelKey: 'Call Trend', specKey: 'spec_model_line' },
{ value: '3', labelKey: 'Call Proportion', specKey: 'spec_pie' },
{ value: '4', labelKey: 'Top Models', specKey: 'spec_rank_bar' },
]
interface ModelChartsProps {
data: QuotaDataItem[]
loading?: boolean
timeGranularity?: TimeGranularity
}
export function ModelCharts(props: ModelChartsProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<ChartTab>('1')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const timeGranularity = props.timeGranularity ?? DEFAULT_TIME_GRANULARITY
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
if (!themeManagerPromise) {
themeManagerPromise = import('@visactor/vchart').then(
(m) => m.ThemeManager
)
}
const ThemeManager = await themeManagerPromise
themeManagerRef.current = ThemeManager
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
setThemeReady(true)
}
updateTheme()
}, [resolvedTheme])
const chartData = useMemo(
() => processChartData(props.loading ? [] : props.data, timeGranularity, t),
[props.data, props.loading, timeGranularity, t]
)
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
const spec = activeSpec ? chartData[activeSpec.specKey] : null
return (
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<PieChartIcon className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t('Model Analytics')}</div>
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TABS.map((tab) => (
<button
key={tab.value}
type='button'
onClick={() => setActiveTab(tab.value)}
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
activeTab === tab.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
<div className='h-96 p-2'>
{themeReady && spec && (
<VChart
key={`${activeTab}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,259 @@
import { useState } from 'react'
import { Filter, RotateCcw, Calendar, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import {
DEFAULT_TIME_GRANULARITY,
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
EMPTY_DASHBOARD_FILTERS,
} from '@/features/dashboard/constants'
import {
cleanFilters,
getSavedGranularity,
saveGranularity,
getDefaultDays,
} from '@/features/dashboard/lib'
import { type DashboardFilters } from '@/features/dashboard/types'
interface ModelsFilterProps {
onFilterChange: (filters: DashboardFilters) => void
onReset: () => void
}
/**
* Section divider component for better visual organization
*/
const SectionDivider = ({ label }: { label: string }) => (
<div className='relative'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background text-muted-foreground px-2'>{label}</span>
</div>
</div>
)
export function ModelsFilter({ onFilterChange, onReset }: ModelsFilterProps) {
const { t } = useTranslation()
// 使用已缓存的用户数据,避免重复调用 API
const user = useAuthStore((state) => state.auth.user)
const isAdmin = user?.role && user.role >= 10
const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<DashboardFilters>(() => {
const granularity = getSavedGranularity()
const days = getDefaultDays(granularity)
const { start, end } = getNormalizedDateRange(days)
return {
...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start,
end_timestamp: end,
time_granularity: granularity,
}
})
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
getDefaultDays()
)
const handleApply = () => {
onFilterChange(
cleanFilters(
filters as unknown as Record<string, unknown>
) as typeof filters
)
setOpen(false)
}
const handleReset = () => {
const days = getDefaultDays(DEFAULT_TIME_GRANULARITY)
const { start, end } = getNormalizedDateRange(days)
setFilters({
...EMPTY_DASHBOARD_FILTERS,
start_timestamp: start,
end_timestamp: end,
time_granularity: DEFAULT_TIME_GRANULARITY,
})
setSelectedRange(days)
saveGranularity(DEFAULT_TIME_GRANULARITY)
onReset()
setOpen(false)
}
const handleChange = (
field: keyof DashboardFilters,
value: Date | string | undefined
) => {
setFilters((prev) => ({ ...prev, [field]: value }))
if (field === 'start_timestamp' || field === 'end_timestamp')
setSelectedRange(null)
if (field === 'time_granularity' && typeof value === 'string')
saveGranularity(value as TimeGranularity)
}
const handleQuickRange = (days: number) => {
const { start, end } = getNormalizedDateRange(days)
setFilters((prev) => ({
...prev,
start_timestamp: start,
end_timestamp: end,
}))
setSelectedRange(days)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</Button>
</DialogTrigger>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription>
{t(
'Set filters to customize your dashboard statistics and charts.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='flex-1 pr-4'>
<div className='grid gap-4 py-4'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='flex gap-2'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
</Button>
<Button onClick={handleApply} type='submit'>
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
interface AnnouncementDetailModalProps {
open: boolean
onOpenChange: (open: boolean) => void
announcement: {
title?: string
content?: string
tag?: string
publishDate?: string
extra?: string
} | null
}
export function AnnouncementDetailModal({
open,
onOpenChange,
announcement,
}: AnnouncementDetailModalProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Announcement Details')}</DialogTitle>
{announcement?.publishDate && (
<DialogDescription>
{t('Published:')}{' '}
{formatDateTimeObject(new Date(announcement.publishDate))}
</DialogDescription>
)}
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,97 @@
import { memo, useState } from 'react'
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getAnnouncementColorClass } from '@/lib/colors'
import { formatDateTimeObject } from '@/lib/time'
import { cn } from '@/lib/utils'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useAnnouncements } from '@/features/dashboard/hooks/use-status-data'
import { getPreviewText } from '@/features/dashboard/lib'
import type { AnnouncementItem } from '@/features/dashboard/types'
import { PanelWrapper } from '../ui/panel-wrapper'
import { AnnouncementDetailModal } from './announcement-detail-dialog'
const AnnouncementStatusDot = memo(function AnnouncementStatusDot(props: {
type?: string
}) {
return (
<span
className={cn(
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
getAnnouncementColorClass(props.type)
)}
/>
)
})
export function AnnouncementsPanel() {
const { t } = useTranslation()
const { items: list, loading } = useAnnouncements()
const [selectedAnnouncement, setSelectedAnnouncement] =
useState<AnnouncementItem | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const handleAnnouncementClick = (item: AnnouncementItem) => {
setSelectedAnnouncement(item)
setIsDialogOpen(true)
}
return (
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Megaphone className='text-muted-foreground/60 size-4' />
{t('Announcements')}
</span>
}
loading={loading}
empty={!list.length}
emptyMessage={t('No announcements at this time')}
height='h-64'
>
<ScrollArea className='h-64'>
<div className='-mx-4 sm:-mx-5'>
{list.map((item: AnnouncementItem, idx: number) => {
const key = item.id ?? `announcement-${idx}`
return (
<button
key={key}
type='button'
onClick={() => handleAnnouncementClick(item)}
className={cn(
'group hover:bg-muted/40 w-full px-4 py-3.5 text-left transition-colors sm:px-5',
idx < list.length - 1 && 'border-border/60 border-b'
)}
>
<div className='flex items-start gap-2.5'>
<AnnouncementStatusDot type={item.type} />
<div className='min-w-0 flex-1 space-y-1'>
<p className='line-clamp-1 text-sm font-medium'>
{getPreviewText(item.content)}
</p>
<div className='flex items-center justify-between'>
{item.publishDate && (
<time className='text-muted-foreground/60 text-xs'>
{formatDateTimeObject(new Date(item.publishDate))}
</time>
)}
<span className='text-muted-foreground/40 text-xs opacity-0 transition-opacity group-hover:opacity-100'>
{t('Click for details')}
</span>
</div>
</div>
</div>
</button>
)
})}
</div>
</ScrollArea>
<AnnouncementDetailModal
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
announcement={selectedAnnouncement}
/>
</PanelWrapper>
)
}
@@ -0,0 +1,125 @@
import { Zap, ExternalLink, Gauge } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getBgColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/copy-button'
import { StatusBadge } from '@/components/status-badge'
import {
getLatencyColorClass,
openExternalSpeedTest,
} from '@/features/dashboard/lib/api-info'
import type { ApiInfoItem, PingStatus } from '@/features/dashboard/types'
interface ApiInfoItemProps {
item: ApiInfoItem
status: PingStatus
onTest: (url: string) => void
}
export function ApiInfoItemComponent(props: ApiInfoItemProps) {
const { t } = useTranslation()
const item = props.item
const status = props.status
return (
<div className='group hover:bg-muted/40 flex items-center justify-between gap-3 px-4 py-3 transition-colors sm:px-5'>
<div className='flex min-w-0 flex-1 items-center gap-3'>
<span
className={cn(
'inline-block size-2 shrink-0 rounded-full',
getBgColorClass(item.color)
)}
/>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<div className='flex items-baseline gap-2'>
<span className='font-mono text-sm font-semibold'>
{item.route}
</span>
<span className='text-muted-foreground/60 hidden truncate text-xs md:inline'>
{item.description}
</span>
</div>
<span className='text-muted-foreground/40 truncate font-mono text-xs'>
{item.url}
</span>
</div>
</div>
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center'>
{status.testing && (
<StatusBadge
label={t('Testing...')}
variant='warning'
className='animate-pulse'
copyable={false}
/>
)}
{status.latency !== null && !status.testing && (
<StatusBadge
variant='success'
label={`${status.latency}${t('ms')}`}
className={cn(
'font-mono font-medium',
getLatencyColorClass(status.latency)
)}
copyable={false}
/>
)}
{status.error && (
<StatusBadge label={t('N/A')} variant='neutral' copyable={false} />
)}
</div>
<div className='flex items-center gap-0.5'>
<Button
variant='ghost'
size='sm'
onClick={() => props.onTest(item.url)}
disabled={status.testing}
className='size-7 p-0'
title={t('Test Latency')}
>
<Zap
className={cn('size-3.5', status.testing && 'animate-pulse')}
/>
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => openExternalSpeedTest(item.url)}
className='size-7 p-0'
title={t('External Speed Test')}
>
<Gauge className='size-3.5' />
</Button>
<CopyButton
value={item.url}
variant='ghost'
size='sm'
className='size-7 p-0'
iconClassName='size-3.5'
tooltip={t('Copy URL')}
aria-label={t('Copy URL')}
/>
<Button
variant='ghost'
size='sm'
asChild
className='size-7 p-0'
title={t('Open in New Tab')}
>
<a href={item.url} target='_blank' rel='noreferrer'>
<ExternalLink className='size-3.5' />
</a>
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,62 @@
import { useState, useCallback } from 'react'
import { Route } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useApiInfo } from '@/features/dashboard/hooks/use-status-data'
import {
testUrlLatency,
getDefaultPingStatus,
} from '@/features/dashboard/lib/api-info'
import type { PingStatusMap, ApiInfoItem } from '@/features/dashboard/types'
import { PanelWrapper } from '../ui/panel-wrapper'
import { ApiInfoItemComponent } from './api-info-item'
export function ApiInfoPanel() {
const { t } = useTranslation()
const { items: list, loading } = useApiInfo()
const [pingStatus, setPingStatus] = useState<PingStatusMap>({})
const handleTest = useCallback(async (url: string) => {
setPingStatus((prev) => ({
...prev,
[url]: { latency: null, testing: true, error: false },
}))
const result = await testUrlLatency(url)
setPingStatus((prev) => ({ ...prev, [url]: result }))
}, [])
return (
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Route className='text-muted-foreground/60 size-4' />
{t('API Info')}
</span>
}
loading={loading}
empty={!list.length}
emptyMessage={t('No API routes configured')}
height='h-64'
>
<ScrollArea className='h-64'>
<div className='-mx-4 sm:-mx-5'>
{list.map((item: ApiInfoItem, idx: number) => (
<div
key={item.url}
className={
idx < list.length - 1 ? 'border-border/60 border-b' : ''
}
>
<ApiInfoItemComponent
item={item}
status={pingStatus[item.url] || getDefaultPingStatus()}
onTest={handleTest}
/>
</div>
))}
</div>
</ScrollArea>
</PanelWrapper>
)
}
@@ -0,0 +1,60 @@
import { HelpCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useFAQ } from '@/features/dashboard/hooks/use-status-data'
import type { FAQItem } from '@/features/dashboard/types'
import { PanelWrapper } from '../ui/panel-wrapper'
export function FAQPanel() {
const { t } = useTranslation()
const { items: list, loading } = useFAQ()
return (
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<HelpCircle className='text-muted-foreground/60 size-4' />
{t('FAQ')}
</span>
}
loading={loading}
empty={!list.length}
emptyMessage={t('No FAQ entries available')}
height='h-80'
>
<ScrollArea className='h-80'>
<Accordion type='single' collapsible className='w-full'>
{list.map((item: FAQItem, idx: number) => {
const key = item.id ?? `faq-${idx}`
const value = `item-${key}`
return (
<AccordionItem
key={key}
value={value}
className='border-border/60'
>
<AccordionTrigger className='text-start hover:no-underline'>
<Markdown className='text-sm leading-relaxed font-semibold'>
{item.question}
</Markdown>
</AccordionTrigger>
<AccordionContent>
<Markdown className='text-muted-foreground/60 text-sm'>
{item.answer}
</Markdown>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</ScrollArea>
</PanelWrapper>
)
}
@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { CreditCard } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/stores/auth-store'
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
import { formatNumber, formatQuota } from '@/lib/format'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
import { StatCard } from '../ui/stat-card'
export function SummaryCards() {
const { t } = useTranslation()
const user = useAuthStore((state) => state.auth.user)
const { status, loading } = useStatus()
const summaryValues = useMemo(() => {
const remainQuota = Number(user?.quota ?? 0)
const usedQuota = Number(user?.used_quota ?? 0)
const requestCount = Number(user?.request_count ?? 0)
return {
remainDisplay: formatQuota(remainQuota),
usedDisplay: formatQuota(usedQuota),
requestCountDisplay: formatNumber(requestCount),
}
}, [user])
const currencyEnabledFromStore = isCurrencyDisplayEnabled()
const statusCurrencyFlag =
typeof status?.display_in_currency === 'boolean'
? Boolean(status.display_in_currency)
: undefined
const currencyEnabled =
statusCurrencyFlag !== undefined
? statusCurrencyFlag
: currencyEnabledFromStore
const currencyLabel = currencyEnabled ? getCurrencyLabel() : 'Tokens'
const items = useSummaryCardsConfig({
...summaryValues,
currencyEnabled,
currencyLabel,
}).map((config, index) => ({
title: config.title,
value: config.value,
desc: config.description,
icon: config.icon,
isBalance: index === 0,
}))
return (
<div className='overflow-hidden rounded-lg border'>
<StaggerContainer className='grid sm:grid-cols-2 lg:grid-cols-3'>
{items.map((it, idx) => (
<StaggerItem
key={it.title}
className={`px-4 sm:px-5 ${
idx > 0 ? 'border-t sm:border-t-0 sm:border-l' : ''
}`}
>
<StatCard
title={it.title}
value={it.value}
description={it.desc}
icon={it.icon}
loading={loading}
action={
it.isBalance ? (
<Button
variant='outline'
size='sm'
className='h-6 gap-1 px-2 text-xs'
asChild
>
<Link to='/wallet'>
<CreditCard className='size-3' />
{t('Recharge')}
</Link>
</Button>
) : undefined
}
/>
</StaggerItem>
))}
</StaggerContainer>
</div>
)
}
@@ -0,0 +1,152 @@
import { memo, useEffect, useState } from 'react'
import { Activity, RotateCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { getUptimeStatus } from '@/features/dashboard/api'
import type {
UptimeGroupResult,
UptimeMonitor,
} from '@/features/dashboard/types'
import { PanelWrapper } from '../ui/panel-wrapper'
const STATUS_COLOR_MAP: Record<number, string> = {
1: 'bg-emerald-500',
0: 'bg-red-500',
2: 'bg-amber-500',
3: 'bg-blue-500',
}
const DEFAULT_STATUS_COLOR = 'bg-muted-foreground/40'
const StatusDot = memo(function StatusDot(props: { status: number }) {
const color = STATUS_COLOR_MAP[props.status] ?? DEFAULT_STATUS_COLOR
return <span className={cn('inline-block size-2 rounded-full', color)} />
})
export function UptimePanel() {
const { t } = useTranslation()
const [groups, setGroups] = useState<UptimeGroupResult[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
useEffect(() => {
const abortController = new AbortController()
getUptimeStatus()
.then((res) => {
if (abortController.signal.aborted) return
setGroups(res?.data || [])
})
.catch(() => {
if (abortController.signal.aborted) return
setGroups([])
})
.finally(() => {
if (!abortController.signal.aborted) {
setLoading(false)
}
})
return () => {
abortController.abort()
}
}, [])
const handleRefresh = () => {
const abortController = new AbortController()
setRefreshing(true)
getUptimeStatus()
.then((res) => {
if (abortController.signal.aborted) return
setGroups(res?.data || [])
})
.catch(() => {
if (abortController.signal.aborted) return
setGroups([])
})
.finally(() => {
if (!abortController.signal.aborted) {
setRefreshing(false)
}
})
}
return (
<PanelWrapper
title={
<span className='flex items-center gap-2'>
<Activity className='text-muted-foreground/60 size-4' />
{t('Uptime')}
</span>
}
loading={loading}
empty={!groups.length}
emptyMessage={t('No uptime monitoring configured')}
height='h-80'
headerActions={
<Button
variant='ghost'
size='sm'
onClick={handleRefresh}
disabled={refreshing}
className='size-7 p-0'
>
<RotateCw
className={cn('size-3.5', refreshing && 'animate-spin')}
aria-label={t('Refresh')}
/>
</Button>
}
>
<ScrollArea className='h-80'>
<div className='-mx-4 space-y-0 sm:-mx-5'>
{groups.map((group, groupIdx) => (
<div key={group.categoryName}>
<div className='bg-muted/30 border-border/60 border-b px-4 py-2 sm:px-5'>
<div className='flex items-center gap-2'>
<h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
{group.categoryName}
</h4>
<span className='text-muted-foreground/40 font-mono text-xs tabular-nums'>
{group.monitors?.length || 0}
</span>
</div>
</div>
{group.monitors?.map(
(monitor: UptimeMonitor, monitorIdx: number) => (
<div
key={monitor.name}
className={cn(
'hover:bg-muted/40 flex items-center justify-between px-4 py-2.5 transition-colors sm:px-5',
monitorIdx < (group.monitors?.length || 0) - 1 &&
'border-border/40 border-b',
groupIdx < groups.length - 1 &&
monitorIdx === (group.monitors?.length || 0) - 1 &&
'border-border/60 border-b'
)}
>
<div className='flex min-w-0 items-center gap-2.5'>
<StatusDot status={monitor.status} />
<span className='truncate text-sm'>{monitor.name}</span>
{monitor.group && (
<span className='text-muted-foreground/40 shrink-0 text-xs'>
({monitor.group})
</span>
)}
</div>
<span className='text-foreground shrink-0 font-mono text-sm font-semibold tabular-nums'>
{((monitor.uptime ?? 0) * 100).toFixed(2)}%
</span>
</div>
)
)}
</div>
))}
</div>
</ScrollArea>
</PanelWrapper>
)
}
@@ -0,0 +1,63 @@
import { type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
interface PanelWrapperProps {
title: ReactNode
loading?: boolean
empty?: boolean
emptyMessage?: string
height?: string
headerActions?: ReactNode
children?: ReactNode
}
export function PanelWrapper(props: PanelWrapperProps) {
const { t } = useTranslation()
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
const height = props.height ?? 'h-64'
if (props.loading) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='border-b px-4 py-3 sm:px-5'>
<div className='text-sm font-semibold'>{props.title}</div>
</div>
<div className='p-4 sm:p-5'>
<Skeleton className={`w-full ${height}`} />
</div>
</div>
)
}
if (props.empty) {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='border-b px-4 py-3 sm:px-5'>
<div className='text-sm font-semibold'>{props.title}</div>
</div>
<div
className={`text-muted-foreground flex items-center justify-center text-sm ${height}`}
>
{resolvedEmptyMessage}
</div>
</div>
)
}
return (
<div className='overflow-hidden rounded-lg border'>
<div className='border-b px-4 py-3 sm:px-5'>
{props.headerActions ? (
<div className='flex items-center justify-between'>
<div className='text-sm font-semibold'>{props.title}</div>
{props.headerActions}
</div>
) : (
<div className='text-sm font-semibold'>{props.title}</div>
)}
</div>
<div className='p-4 sm:p-5'>{props.children}</div>
</div>
)
}
@@ -0,0 +1,53 @@
import { type LucideIcon } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
interface StatCardProps {
title: string
value: string | number
description: string
icon: LucideIcon
loading?: boolean
error?: boolean
action?: React.ReactNode
}
export function StatCard(props: StatCardProps) {
const Icon = props.icon
return (
<div className='group flex flex-col gap-1.5 py-3'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
<Icon className='text-muted-foreground/60 size-3.5' />
{props.title}
</div>
{props.action}
</div>
{props.loading ? (
<div className='space-y-1.5'>
<Skeleton className='h-7 w-24' />
<Skeleton className='h-3.5 w-32' />
</div>
) : props.error ? (
<>
<div className='text-muted-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
--
</div>
<p className='text-muted-foreground/60 text-xs'>
{props.description}
</p>
</>
) : (
<>
<div className='text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums'>
{props.value}
</div>
<p className='text-muted-foreground/60 text-xs'>
{props.description}
</p>
</>
)}
</div>
)
}
@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart'
import { Users, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getNormalizedDateRange, type TimeGranularity } from '@/lib/time'
import { VCHART_OPTION } from '@/lib/vchart'
import { useTheme } from '@/context/theme-provider'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
} from '@/features/dashboard/constants'
import {
getDefaultDays,
getSavedGranularity,
saveGranularity,
processUserChartData,
} from '@/features/dashboard/lib'
import type { ProcessedUserChartData } from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
> | null = null
type UserChartTab = 'rank' | 'trend'
const CHART_TABS: {
value: UserChartTab
labelKey: string
specKey: keyof ProcessedUserChartData
}[] = [
{
value: 'rank',
labelKey: 'User Consumption Ranking',
specKey: 'spec_user_rank',
},
{
value: 'trend',
labelKey: 'User Consumption Trend',
specKey: 'spec_user_trend',
},
]
export function UserCharts() {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [activeTab, setActiveTab] = useState<UserChartTab>('rank')
const [themeReady, setThemeReady] = useState(false)
const themeManagerRef = useRef<
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
getSavedGranularity()
)
const [selectedRange, setSelectedRange] = useState<number>(() =>
getDefaultDays(timeGranularity)
)
const [timeRange, setTimeRange] = useState(() => {
const days = getDefaultDays(timeGranularity)
const { start, end } = getNormalizedDateRange(days)
return {
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
}
})
const handleRangeChange = useCallback((days: number) => {
setSelectedRange(days)
const { start, end } = getNormalizedDateRange(days)
setTimeRange({
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
})
}, [])
const handleGranularityChange = useCallback(
(g: TimeGranularity) => {
setTimeGranularity(g)
saveGranularity(g)
const days = getDefaultDays(g)
if (days !== selectedRange) {
handleRangeChange(days)
}
},
[selectedRange, handleRangeChange]
)
useEffect(() => {
const updateTheme = async () => {
setThemeReady(false)
if (!themeManagerPromise) {
themeManagerPromise = import('@visactor/vchart').then(
(m) => m.ThemeManager
)
}
const ThemeManager = await themeManagerPromise
themeManagerRef.current = ThemeManager
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
setThemeReady(true)
}
updateTheme()
}, [resolvedTheme])
const { data: userData, isLoading } = useQuery({
queryKey: ['dashboard', 'user-quota', timeRange],
queryFn: () => getUserQuotaDataByUsers(timeRange),
select: (res) => (res.success ? res.data : []),
staleTime: 60_000,
})
const chartData = useMemo(
() =>
processUserChartData(
isLoading ? [] : (userData ?? []),
timeGranularity,
t
),
[userData, isLoading, timeGranularity, t]
)
const activeSpec = CHART_TABS.find((tab) => tab.value === activeTab)
const spec = activeSpec ? chartData[activeSpec.specKey] : null
return (
<div className='space-y-4'>
{/* Toolbar: time range presets + granularity */}
<div className='flex flex-wrap items-center gap-2'>
<div className='flex items-center gap-1.5 rounded-md border p-0.5'>
{TIME_RANGE_PRESETS.map((preset) => (
<button
key={preset.days}
type='button'
onClick={() => handleRangeChange(preset.days)}
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
selectedRange === preset.days
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t(preset.label)}
</button>
))}
</div>
<div className='flex items-center gap-1.5 rounded-md border p-0.5'>
{TIME_GRANULARITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type='button'
onClick={() =>
handleGranularityChange(opt.value as TimeGranularity)
}
className={`rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors ${
timeGranularity === opt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t(opt.label)}
</button>
))}
</div>
{isLoading && (
<Loader2 className='text-muted-foreground size-4 animate-spin' />
)}
</div>
{/* Chart card */}
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-3 border-b px-4 py-3 sm:px-5 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex items-center gap-2'>
<Users className='text-muted-foreground/60 size-4' />
<div className='text-sm font-semibold'>{t('User Analytics')}</div>
</div>
<div className='bg-muted/60 inline-flex h-8 rounded-md border p-0.5'>
{CHART_TABS.map((tab) => (
<button
key={tab.value}
type='button'
onClick={() => setActiveTab(tab.value)}
className={`rounded-[5px] px-3 text-xs font-medium transition-colors ${
activeTab === tab.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
<div className='h-96 p-2'>
{isLoading ? (
<Skeleton className='h-full w-full' />
) : (
themeReady &&
spec && (
<VChart
key={`user-${activeTab}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)
)}
</div>
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import type { DashboardFilters } from './types'
export const TIME_GRANULARITY_STORAGE_KEY = 'data_export_default_time'
export const DEFAULT_TIME_GRANULARITY = 'hour' as const
export const MAX_CHART_TREND_POINTS = 7
export const TIME_RANGE_BY_GRANULARITY = {
hour: 1,
day: 7,
week: 30,
} as const
export const TIME_GRANULARITY_OPTIONS = [
{ label: 'Hour', value: 'hour' },
{ label: 'Day', value: 'day' },
{ label: 'Week', value: 'week' },
] as const
export const TIME_RANGE_PRESETS = [
{ label: '1 Day', days: 1 },
{ label: '7 Days', days: 7 },
{ label: '14 Days', days: 14 },
{ label: '29 Days', days: 29 },
] as const
export const EMPTY_DASHBOARD_FILTERS: DashboardFilters = {
start_timestamp: undefined,
end_timestamp: undefined,
time_granularity: 'hour',
username: '',
}
@@ -0,0 +1,107 @@
import {
Hash,
Coins,
Layers,
Gauge,
Zap,
Wallet,
TrendingUp,
Activity,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { safeDivide } from '@/features/dashboard/lib'
interface StatCardConfig {
key: string
title: string
description: string
icon: LucideIcon
getValue: (stat: Record<string, number>, days?: number) => number
}
export function useModelStatCardsConfig(): StatCardConfig[] {
const { t } = useTranslation()
return [
{
key: 'count',
title: t('Total Count'),
description: t('Statistical count'),
icon: Hash,
getValue: (stat) => stat?.rpm ?? 0,
},
{
key: 'quota',
title: t('Total Quota'),
description: t('Statistical quota'),
icon: Coins,
getValue: (stat) => stat?.quota ?? 0,
},
{
key: 'tokens',
title: t('Total Tokens'),
description: t('Statistical tokens'),
icon: Layers,
getValue: (stat) => stat?.tpm ?? 0,
},
{
key: 'avgRpm',
title: t('Average RPM'),
description: t('Requests per minute'),
icon: Gauge,
getValue: (stat, timeRangeMinutes = 1) =>
safeDivide(stat?.rpm ?? 0, timeRangeMinutes),
},
{
key: 'avgTpm',
title: t('Average TPM'),
description: t('Tokens per minute'),
icon: Zap,
getValue: (stat, timeRangeMinutes = 1) =>
safeDivide(stat?.tpm ?? 0, timeRangeMinutes),
},
]
}
export function useSummaryCardsConfig(totals: {
remainDisplay: string
usedDisplay: string
requestCountDisplay: string
currencyLabel: string
currencyEnabled: boolean
}) {
const { t } = useTranslation()
return [
{
key: 'balance',
title: totals.currencyEnabled
? `${t('Current Balance')} (${totals.currencyLabel})`
: t('Current Balance'),
value: totals.remainDisplay,
description: totals.currencyEnabled
? `${t('Remaining quota')} (${totals.currencyLabel})`
: t('Remaining quota units'),
icon: Wallet,
},
{
key: 'usage',
title: totals.currencyEnabled
? `${t('Historical Usage')} (${totals.currencyLabel})`
: t('Historical Usage'),
value: totals.usedDisplay,
description: totals.currencyEnabled
? `${t('Total consumed')} (${totals.currencyLabel})`
: t('Total consumed quota'),
icon: TrendingUp,
},
{
key: 'requests',
title: t('Request Count'),
value: totals.requestCountDisplay,
description: t('Total requests made'),
icon: Activity,
},
]
}
@@ -0,0 +1,40 @@
import { useStatus } from '@/hooks/use-status'
import type { AnnouncementItem, ApiInfoItem, FAQItem } from '../types'
/**
* Get specific list from status data
*/
export function useStatusData<T = unknown>(
enabledKey: string,
dataKey: string
): { items: T[]; loading: boolean } {
const { status, loading } = useStatus()
const enabled = status?.[enabledKey] ?? false
const items = (enabled ? status?.[dataKey] || [] : []) as T[]
return { items, loading }
}
/**
* Get API info list
*/
export function useApiInfo() {
return useStatusData<ApiInfoItem>('api_info_enabled', 'api_info')
}
/**
* Get announcements list
*/
export function useAnnouncements() {
return useStatusData<AnnouncementItem>(
'announcements_enabled',
'announcements'
)
}
/**
* Get FAQ list
*/
export function useFAQ() {
return useStatusData<FAQItem>('faq_enabled', 'faq')
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useCallback, lazy, Suspense } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { SectionPageLayout } from '@/components/layout'
import {
CardStaggerContainer,
CardStaggerItem,
FadeIn,
} from '@/components/page-transition'
import { ModelsFilter } from './components/models/models-filter-dialog'
import { AnnouncementsPanel } from './components/overview/announcements-panel'
import { ApiInfoPanel } from './components/overview/api-info-panel'
import { FAQPanel } from './components/overview/faq-panel'
import { SummaryCards } from './components/overview/summary-cards'
import { UptimePanel } from './components/overview/uptime-panel'
import { DEFAULT_TIME_GRANULARITY } from './constants'
import {
type DashboardSectionId,
DASHBOARD_DEFAULT_SECTION,
} from './section-registry'
import { type DashboardFilters, type QuotaDataItem } from './types'
const route = getRouteApi('/_authenticated/dashboard/$section')
const LazyLogStatCards = lazy(() =>
import('./components/models/log-stat-cards').then((m) => ({
default: m.LogStatCards,
}))
)
const LazyModelCharts = lazy(() =>
import('./components/models/model-charts').then((m) => ({
default: m.ModelCharts,
}))
)
const LazyUserCharts = lazy(() =>
import('./components/users/user-charts').then((m) => ({
default: m.UserCharts,
}))
)
function LogStatCardsFallback() {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-3 lg:grid-cols-5'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-16' />
<Skeleton className='mt-2 h-7 w-20' />
<Skeleton className='mt-1.5 h-3.5 w-28' />
</div>
))}
</div>
</div>
)
}
function ModelChartsFallback() {
return (
<div className='overflow-hidden rounded-lg border'>
<div className='flex items-center justify-between border-b px-4 py-3 sm:px-5'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-8 w-72' />
</div>
<div className='h-96 p-2'>
<Skeleton className='h-full w-full' />
</div>
</div>
)
}
const SECTION_META: Record<
DashboardSectionId,
{ titleKey: string; descriptionKey: string }
> = {
overview: {
titleKey: 'Overview',
descriptionKey: 'View dashboard overview and statistics',
},
models: {
titleKey: 'Models',
descriptionKey: 'View model statistics and charts',
},
users: {
titleKey: 'User Analytics',
descriptionKey: 'View user consumption statistics and charts',
},
}
export function Dashboard() {
const { t } = useTranslation()
const params = route.useParams()
const activeSection = (params.section ??
DASHBOARD_DEFAULT_SECTION) as DashboardSectionId
const [modelFilters, setModelFilters] = useState<DashboardFilters>({})
const [modelData, setModelData] = useState<QuotaDataItem[]>([])
const [dataLoading, setDataLoading] = useState(false)
const handleFilterChange = useCallback((filters: DashboardFilters) => {
setModelFilters(filters)
}, [])
const handleResetFilters = useCallback(() => {
setModelFilters({})
}, [])
const handleDataUpdate = useCallback(
(data: QuotaDataItem[], loading: boolean) => {
setModelData(data)
setDataLoading(loading)
},
[]
)
const meta = SECTION_META[activeSection] ?? SECTION_META.overview
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t(meta.descriptionKey)}
</SectionPageLayout.Description>
{activeSection === 'models' && (
<SectionPageLayout.Actions>
<ModelsFilter
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
/>
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content>
<div className='space-y-4'>
{activeSection === 'overview' && (
<>
<SummaryCards />
<CardStaggerContainer className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<CardStaggerItem>
<ApiInfoPanel />
</CardStaggerItem>
<CardStaggerItem>
<AnnouncementsPanel />
</CardStaggerItem>
<CardStaggerItem>
<FAQPanel />
</CardStaggerItem>
<CardStaggerItem>
<UptimePanel />
</CardStaggerItem>
</CardStaggerContainer>
</>
)}
{activeSection === 'models' && (
<>
<FadeIn>
<Suspense fallback={<LogStatCardsFallback />}>
<LazyLogStatCards
filters={modelFilters}
onDataUpdate={handleDataUpdate}
/>
</Suspense>
</FadeIn>
<FadeIn delay={0.1}>
<Suspense fallback={<ModelChartsFallback />}>
<LazyModelCharts
data={modelData}
loading={dataLoading}
timeGranularity={
modelFilters.time_granularity || DEFAULT_TIME_GRANULARITY
}
/>
</Suspense>
</FadeIn>
</>
)}
{activeSection === 'users' && (
<FadeIn>
<Suspense fallback={<ModelChartsFallback />}>
<LazyUserCharts />
</Suspense>
</FadeIn>
)}
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
}

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