diff --git a/docs/plans/2026-01-24-mobile-leaderboard-design.md b/docs/plans/2026-01-24-mobile-leaderboard-design.md new file mode 100644 index 000000000..eb05faa01 --- /dev/null +++ b/docs/plans/2026-01-24-mobile-leaderboard-design.md @@ -0,0 +1,129 @@ +# Mobile Leaderboard Optimization Design + +## Problem + +The leaderboard page has usability issues on mobile devices: + +1. **Tab labels truncated** - "供应商缓存命中率排行" gets cut off, appearing as garbled text +2. **Table too cramped** - 4+ columns squeezed into narrow viewport +3. **Filter inputs crowded** - Two TagInputs side by side have limited width + +## Solution Overview + +Transform the leaderboard from table-based layout to card-based layout on mobile, with simplified tab labels and stacked filter inputs. + +## Design Details + +### 1. Tab Label Simplification + +Use shorter labels on mobile (< 768px): + +| Desktop | Mobile | +|---------|--------| +| 用户排行 | 用户 | +| 供应商排行 | 供应商 | +| 供应商缓存命中率排行 | 缓存率 | +| 模型排行 | 模型 | + +Implementation: Use `useIsMobile()` hook to conditionally render tab labels. + +### 2. Card-Based Layout + +Replace table with expandable cards on mobile. + +#### Default View (Collapsed) + +``` +┌─────────────────────────────────────┐ +│ 🏆 #1 username $18.01M │ → tap to expand +├─────────────────────────────────────┤ +│ 🥈 #2 another_user $5.32M │ +├─────────────────────────────────────┤ +│ 🥉 #3 test_account $2.10M │ +└─────────────────────────────────────┘ +``` + +- Left: Rank badge (reuse existing Trophy/Medal/Award icons) +- Center: Name (user/provider/model depending on scope) +- Right: Primary metric (cost/tokens) +- Visual: Top 3 highlighted with `bg-muted/50` + +#### Expanded View + +``` +┌─────────────────────────────────────┐ +│ 🏆 #1 default ▲ 收起 │ +│─────────────────────────────────────│ +│ 请求数 Token数 消耗 │ +│ 299 18.01M $12.50 │ +└─────────────────────────────────────┘ +``` + +Fields by scope: + +| Scope | Expanded Fields | +|-------|-----------------| +| User | requests, tokens, cost | +| Provider | requests, cost, tokens, successRate, avgTtfbMs, avgTokensPerSecond | +| CacheHitRate | requests, cacheHitRate, cacheReadTokens, totalInputTokens | +| Model | requests, tokens, cost, successRate | + +Layout: +- 3-4 fields: single row `grid-cols-3` +- 5-6 fields: two rows `grid-cols-3` + +### 3. Filter Area + +Mobile layout (stacked): + +``` +┌─────────────────────────────────────┐ +│ [按用户标签筛选... ] │ ← full width +│ [按用户分组筛选... ] │ ← full width +├─────────────────────────────────────┤ +│ [今日] [本周] [本月] [全部] │ ← keep horizontal +│ [<] [2026-01-24 ] [>] │ +└─────────────────────────────────────┘ +``` + +Changes: +- TagInputs stack vertically on mobile +- Each TagInput takes full width +- Date picker area remains unchanged + +### 4. Responsive Switching + +Use existing `useIsMobile()` hook from `src/lib/hooks/use-mobile.ts`: +- Breakpoint: 768px +- Mobile (< 768px): Render card components +- Desktop (>= 768px): Keep existing table + +## Implementation Plan + +### Files to Create + +1. `src/app/[locale]/dashboard/leaderboard/_components/mobile-leaderboard-card.tsx` + - Reusable card component for all scopes + - Props: rank, data, scope, expanded, onToggle + +### Files to Modify + +1. `src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx` + - Add `useIsMobile()` hook + - Conditionally render mobile tabs labels + - Stack TagInputs on mobile + - Render cards instead of table on mobile + +2. `messages/*/dashboard/leaderboard.json` (all 5 languages) + - Add short tab labels: `tabs.userRankingShort`, `tabs.providerRankingShort`, etc. + +### Files Unchanged + +- `leaderboard-table.tsx` - Desktop table component, no changes needed +- `date-range-picker.tsx` - Already works on mobile + +## Related Work + +This follows the same pattern as the mobile logs optimization: +- `src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx` +- `src/app/[locale]/dashboard/logs/_components/mobile-logs-list.tsx` diff --git a/docs/plans/2026-01-24-mobile-usage-logs-design.md b/docs/plans/2026-01-24-mobile-usage-logs-design.md new file mode 100644 index 000000000..c9a6e38bd --- /dev/null +++ b/docs/plans/2026-01-24-mobile-usage-logs-design.md @@ -0,0 +1,122 @@ +# Mobile Usage Logs Card Layout Design + +## Overview + +Optimize the usage logs display on mobile devices (< 768px) by replacing the table layout with a card-based layout that shows all essential information without truncation. + +## Problem + +Current table layout on mobile: +- 11 columns squeezed into narrow screen +- Content truncated with `...` everywhere +- Users cannot see complete information +- Poor mobile browsing experience + +## Solution + +Switch to card-based layout on mobile while keeping desktop table unchanged. + +## Card Structure + +``` ++-------------------------------------+ +| [Header] Time + Status Badge | +| Left: Relative time (3s ago) | +| Right: Status badge (OK 200) | ++-------------------------------------+ +| [Identity] User + Provider + Model | +| Username - Provider name | +| Model name (with redirect arrow) | ++-------------------------------------+ +| [Data] Tokens + Cache + Cost | +| Col1: Input/Output tokens | +| Col2: Cache write/read | +| Col3: Cost amount | ++-------------------------------------+ +| [Performance] Duration + TTFB + Rate| +| Total time - TTFB - Output rate | ++-------------------------------------+ +``` + +## Visual Design + +### Card Base Style +- Border radius: `rounded-lg` +- Border: `border` +- Gap between cards: `gap-3` (12px) +- Padding: `p-3` (12px) +- Click feedback: slight press effect + +### Status Badges + +| Status | Style | Example | +|--------|-------|---------| +| Success (200) | Green background | `OK 200` | +| Client error (4xx) | Orange background | `! 429` | +| Server error (5xx) | Red background | `X 500` | +| Blocked | Orange outline | `Blocked` | + +### Special States +- **Session resume**: Small tag after provider name +- **Model redirect**: Arrow display `gpt-4 -> claude-sonnet` +- **Cost multiplier**: Badge next to cost `x1.50` +- **Non-billing request**: Muted card background `bg-muted/60` + +### Data Section Layout +``` ++-----------+-----------+---------+ +| Tokens | Cache | Cost | +| In: 1.2K | W: 500 | $0.0234 | +| Out: 856 | R: 2.1K | | ++-----------+-----------+---------+ +``` +- Three equal-width columns +- Numbers right-aligned +- Labels left-aligned + +## Implementation + +### File Structure +``` +src/app/[locale]/dashboard/logs/_components/ +├── virtualized-logs-table.tsx # Add responsive detection +├── mobile-log-card.tsx # New: Single card component +└── mobile-logs-list.tsx # New: Mobile card list with virtual scroll +``` + +### Changes + +1. **virtualized-logs-table.tsx** + - Import `useIsMobile()` hook + - Render `MobileLogsList` when `isMobile` + - Keep existing table for desktop + +2. **mobile-log-card.tsx** + - Accept single log data + - Render four sections (header/identity/data/performance) + - Click triggers `onCardClick` to open detail dialog + +3. **mobile-logs-list.tsx** + - Reuse existing `useInfiniteQuery` logic + - Reuse existing `useVirtualizer` for virtual scrolling + - Adjust `ROW_HEIGHT` to card height (~140px) + +### Reused Components +- Data fetching: `getUsageLogsBatch` +- Detail dialog: `ErrorDetailsDialog` +- Time format: `RelativeTime` +- Currency format: `formatCurrency` +- Token format: `formatTokenAmount` + +## Interaction + +- Tap card: Open detail dialog (reuse ErrorDetailsDialog) +- Virtual scrolling: Infinite scroll with auto-fetch +- Pull to refresh: Supported via existing refresh logic + +## Responsive Breakpoint + +- `< 768px`: Card layout (mobile) +- `>= 768px`: Table layout (desktop) + +Detection via `useIsMobile()` hook from `@/lib/hooks/use-mobile.ts` diff --git a/drizzle/0057_jazzy_blink.sql b/drizzle/0057_jazzy_blink.sql new file mode 100644 index 000000000..7ba377dd0 --- /dev/null +++ b/drizzle/0057_jazzy_blink.sql @@ -0,0 +1,2 @@ +ALTER TABLE "providers" ALTER COLUMN "provider_vendor_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "providers" ADD COLUMN "group_priorities" jsonb DEFAULT 'null'::jsonb; \ No newline at end of file diff --git a/drizzle/meta/0057_snapshot.json b/drizzle/meta/0057_snapshot.json new file mode 100644 index 000000000..8a1203a61 --- /dev/null +++ b/drizzle/meta/0057_snapshot.json @@ -0,0 +1,2897 @@ +{ + "id": "b79d203b-7947-4882-885d-4abe3fbaa20d", + "prevId": "75eef188-0cac-4ae8-9deb-9b0db4f046c2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 87303a2fd..5a78beed1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1769008812140, "tag": "0056_tidy_quasar", "breakpoints": true + }, + { + "idx": 57, + "version": "7", + "when": 1769051987264, + "tag": "0057_jazzy_blink", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 430a23e9a..05b25951f 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -371,9 +371,13 @@ "users": "User Rankings", "keys": "Key Rankings", "userRanking": "User Rankings", + "userRankingShort": "User", "providerRanking": "Provider Rankings", + "providerRankingShort": "Provider", "providerCacheHitRateRanking": "Provider Cache Hit Rate", + "providerCacheHitRateRankingShort": "Cache", "modelRanking": "Model Rankings", + "modelRankingShort": "Model", "dailyRanking": "Today", "weeklyRanking": "This Week", "monthlyRanking": "This Month", diff --git a/messages/en/settings/index.ts b/messages/en/settings/index.ts index 47a6b5424..aee9d4601 100644 --- a/messages/en/settings/index.ts +++ b/messages/en/settings/index.ts @@ -15,6 +15,8 @@ import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; import providersBatchEdit from "./providers/batchEdit.json"; import providersFilter from "./providers/filter.json"; +import providersGroupEdit from "./providers/groupEdit.json"; +import providersGroupPriority from "./providers/groupPriority.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; @@ -78,6 +80,8 @@ const providers = { batchEdit: providersBatchEdit, filter: providersFilter, form: providersForm, + groupEdit: providersGroupEdit, + groupPriority: providersGroupPriority, guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, diff --git a/messages/en/settings/providers/filter.json b/messages/en/settings/providers/filter.json index 1f1513993..2030fea47 100644 --- a/messages/en/settings/providers/filter.json +++ b/messages/en/settings/providers/filter.json @@ -1,13 +1,23 @@ { + "apply": "Apply Filters", "circuitBroken": "Circuit Broken", "groups": { "all": "All", "default": "default", "label": "Groups:" }, + "reset": "Reset", + "sort": { + "label": "Sort By:" + }, "status": { "active": "Active", "all": "Any status", - "inactive": "Inactive" + "inactive": "Inactive", + "label": "Status:" + }, + "title": "Filter Options", + "type": { + "label": "Type:" } } diff --git a/messages/en/settings/providers/groupEdit.json b/messages/en/settings/providers/groupEdit.json new file mode 100644 index 000000000..d45ae94e3 --- /dev/null +++ b/messages/en/settings/providers/groupEdit.json @@ -0,0 +1,12 @@ +{ + "title": "Select Groups", + "addPlaceholder": "Add new group...", + "add": "Add", + "saveSuccess": "Group updated", + "saveFailed": "Failed to update group", + "noGroups": "No groups available", + "defaultGroup": "default", + "invalidGroupName": "Invalid group name", + "reservedGroupName": "Reserved group name", + "commaNotAllowed": "Comma is not allowed in group name" +} diff --git a/messages/en/settings/providers/groupPriority.json b/messages/en/settings/providers/groupPriority.json new file mode 100644 index 000000000..d2963b846 --- /dev/null +++ b/messages/en/settings/providers/groupPriority.json @@ -0,0 +1,6 @@ +{ + "title": "Group Priority", + "default": "Default", + "emptyHint": "Leave empty to use default", + "hasOverrides": "Has group-level priority overrides" +} diff --git a/messages/en/settings/providers/list.json b/messages/en/settings/providers/list.json index 6708c1d1c..85a39f4e4 100644 --- a/messages/en/settings/providers/list.json +++ b/messages/en/settings/providers/list.json @@ -1,6 +1,8 @@ { "cancelButton": "Cancel", "circuitBroken": "Circuit Broken", + "clone": "Clone", + "delete": "Delete", "clipboardUnavailable": "Clipboard access is blocked in this environment. Select and copy the key manually.", "confirmDeleteMessage": "Are you sure you want to delete provider \"{name}\"? This action cannot be undone.", "confirmDeleteTitle": "Confirm Delete Provider?", @@ -11,11 +13,13 @@ "deleteFailed": "Delete failed", "deleteSuccess": "Deleted successfully", "deleteSuccessDesc": "Provider \"{name}\" has been deleted", + "edit": "Edit", "getKeyFailed": "Failed to get key", "keyCopied": "Key copied to clipboard", "keyLoading": "Loading...", "officialWebsite": "Official", "priority": "Priority", + "resetCircuit": "Reset Circuit", "resetCircuitFailed": "Failed to reset circuit breaker", "resetCircuitSuccess": "Circuit breaker reset", "resetCircuitSuccessDesc": "Provider \"{name}\" circuit breaker status cleared", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 073ef724d..8147c264e 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -371,9 +371,13 @@ "users": "ユーザー ランキング", "keys": "キー ランキング", "userRanking": "ユーザーランキング", + "userRankingShort": "ユーザー", "providerRanking": "プロバイダーランキング", + "providerRankingShort": "プロバイダー", "providerCacheHitRateRanking": "プロバイダーキャッシュ命中率", + "providerCacheHitRateRankingShort": "キャッシュ率", "modelRanking": "モデルランキング", + "modelRankingShort": "モデル", "dailyRanking": "本日", "weeklyRanking": "今週", "monthlyRanking": "今月", diff --git a/messages/ja/settings/index.ts b/messages/ja/settings/index.ts index 47a6b5424..aee9d4601 100644 --- a/messages/ja/settings/index.ts +++ b/messages/ja/settings/index.ts @@ -15,6 +15,8 @@ import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; import providersBatchEdit from "./providers/batchEdit.json"; import providersFilter from "./providers/filter.json"; +import providersGroupEdit from "./providers/groupEdit.json"; +import providersGroupPriority from "./providers/groupPriority.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; @@ -78,6 +80,8 @@ const providers = { batchEdit: providersBatchEdit, filter: providersFilter, form: providersForm, + groupEdit: providersGroupEdit, + groupPriority: providersGroupPriority, guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, diff --git a/messages/ja/settings/providers/filter.json b/messages/ja/settings/providers/filter.json index 0e99db282..34ee5cebd 100644 --- a/messages/ja/settings/providers/filter.json +++ b/messages/ja/settings/providers/filter.json @@ -1,13 +1,23 @@ { + "apply": "フィルターを適用", "circuitBroken": "サーキットブレーカー", "groups": { "all": "すべて", "default": "default", "label": "グループ:" }, + "reset": "リセット", + "sort": { + "label": "並び替え:" + }, "status": { "active": "有効", "all": "すべてのステータス", - "inactive": "無効" + "inactive": "無効", + "label": "ステータス:" + }, + "title": "フィルターオプション", + "type": { + "label": "タイプ:" } } diff --git a/messages/ja/settings/providers/groupEdit.json b/messages/ja/settings/providers/groupEdit.json new file mode 100644 index 000000000..13ed0086a --- /dev/null +++ b/messages/ja/settings/providers/groupEdit.json @@ -0,0 +1,12 @@ +{ + "title": "グループを選択", + "addPlaceholder": "新しいグループを追加...", + "add": "追加", + "saveSuccess": "グループを更新しました", + "saveFailed": "グループの更新に失敗しました", + "noGroups": "利用可能なグループがありません", + "defaultGroup": "デフォルト", + "invalidGroupName": "無効なグループ名", + "reservedGroupName": "予約されたグループ名", + "commaNotAllowed": "グループ名にカンマは使用できません" +} diff --git a/messages/ja/settings/providers/groupPriority.json b/messages/ja/settings/providers/groupPriority.json new file mode 100644 index 000000000..d3a6769df --- /dev/null +++ b/messages/ja/settings/providers/groupPriority.json @@ -0,0 +1,6 @@ +{ + "title": "グループ優先度", + "default": "デフォルト", + "emptyHint": "空欄の場合はデフォルトを使用", + "hasOverrides": "グループレベルの優先度オーバーライドあり" +} diff --git a/messages/ja/settings/providers/list.json b/messages/ja/settings/providers/list.json index 32793f63b..f01f1f1fd 100644 --- a/messages/ja/settings/providers/list.json +++ b/messages/ja/settings/providers/list.json @@ -1,6 +1,8 @@ { "cancelButton": "キャンセル", "circuitBroken": "遮断中", + "clone": "複製", + "delete": "削除", "clipboardUnavailable": "この環境ではクリップボードを使用できません。手動でコピーしてください。", "confirmDeleteMessage": "プロバイダー \"{name}\" を削除してもよろしいですか?この操作は元に戻せません。", "confirmDeleteTitle": "プロバイダーの削除を確認しますか?", @@ -11,11 +13,13 @@ "deleteFailed": "削除に失敗しました", "deleteSuccess": "削除に成功しました", "deleteSuccessDesc": "プロバイダー \"{name}\" が削除されました", + "edit": "編集", "getKeyFailed": "キーの取得に失敗しました", "keyCopied": "キーがクリップボードにコピーされました", "keyLoading": "読み込み中...", "officialWebsite": "公式", "priority": "優先度", + "resetCircuit": "遮断リセット", "resetCircuitFailed": "サーキットブレーカーのリセットに失敗しました", "resetCircuitSuccess": "サーキットブレーカーがリセットされました", "resetCircuitSuccessDesc": "プロバイダー \"{name}\" のサーキットブレーカーステータスがクリアされました", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 629c28316..7adc79266 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -371,9 +371,13 @@ "users": "Рейтинг пользователей", "keys": "Рейтинг ключей", "userRanking": "Рейтинг пользователей", + "userRankingShort": "Польз.", "providerRanking": "Рейтинг поставщиков", + "providerRankingShort": "Пост.", "providerCacheHitRateRanking": "Рейтинг по попаданиям в кэш", + "providerCacheHitRateRankingShort": "Кэш", "modelRanking": "Рейтинг моделей", + "modelRankingShort": "Модель", "dailyRanking": "Сегодня", "weeklyRanking": "Эта неделя", "monthlyRanking": "Этот месяц", diff --git a/messages/ru/settings/index.ts b/messages/ru/settings/index.ts index 47a6b5424..aee9d4601 100644 --- a/messages/ru/settings/index.ts +++ b/messages/ru/settings/index.ts @@ -15,6 +15,8 @@ import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; import providersBatchEdit from "./providers/batchEdit.json"; import providersFilter from "./providers/filter.json"; +import providersGroupEdit from "./providers/groupEdit.json"; +import providersGroupPriority from "./providers/groupPriority.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; @@ -78,6 +80,8 @@ const providers = { batchEdit: providersBatchEdit, filter: providersFilter, form: providersForm, + groupEdit: providersGroupEdit, + groupPriority: providersGroupPriority, guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, diff --git a/messages/ru/settings/providers/filter.json b/messages/ru/settings/providers/filter.json index 075abdc72..0a79f538f 100644 --- a/messages/ru/settings/providers/filter.json +++ b/messages/ru/settings/providers/filter.json @@ -1,13 +1,23 @@ { + "apply": "Применить фильтры", "circuitBroken": "Сбой соединения", "groups": { "all": "Все", "default": "default", "label": "Группы:" }, + "reset": "Сбросить", + "sort": { + "label": "Сортировка:" + }, "status": { "active": "Активные", "all": "Все статусы", - "inactive": "Неактивные" + "inactive": "Неактивные", + "label": "Статус:" + }, + "title": "Параметры фильтра", + "type": { + "label": "Тип:" } } diff --git a/messages/ru/settings/providers/groupEdit.json b/messages/ru/settings/providers/groupEdit.json new file mode 100644 index 000000000..a82a5a075 --- /dev/null +++ b/messages/ru/settings/providers/groupEdit.json @@ -0,0 +1,12 @@ +{ + "title": "Выбрать группы", + "addPlaceholder": "Добавить новую группу...", + "add": "Добавить", + "saveSuccess": "Группа обновлена", + "saveFailed": "Не удалось обновить группу", + "noGroups": "Нет доступных групп", + "defaultGroup": "по умолчанию", + "invalidGroupName": "Недопустимое имя группы", + "reservedGroupName": "Зарезервированное имя группы", + "commaNotAllowed": "Запятая не допускается в имени группы" +} diff --git a/messages/ru/settings/providers/groupPriority.json b/messages/ru/settings/providers/groupPriority.json new file mode 100644 index 000000000..6e4c45b1d --- /dev/null +++ b/messages/ru/settings/providers/groupPriority.json @@ -0,0 +1,6 @@ +{ + "title": "Приоритет группы", + "default": "По умолчанию", + "emptyHint": "Оставьте пустым для использования значения по умолчанию", + "hasOverrides": "Есть переопределения приоритета на уровне группы" +} diff --git a/messages/ru/settings/providers/list.json b/messages/ru/settings/providers/list.json index 4a6e90a4f..287d1bf35 100644 --- a/messages/ru/settings/providers/list.json +++ b/messages/ru/settings/providers/list.json @@ -1,6 +1,8 @@ { "cancelButton": "Отмена", "circuitBroken": "Разорвано", + "clone": "Клонировать", + "delete": "Удалить", "clipboardUnavailable": "Буфер обмена недоступен в этой среде. Скопируйте ключ вручную.", "confirmDeleteMessage": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие нельзя отменить.", "confirmDeleteTitle": "Подтвердить удаление провайдера?", @@ -11,11 +13,13 @@ "deleteFailed": "Не удалось удалить", "deleteSuccess": "Успешно удалено", "deleteSuccessDesc": "Провайдер \"{name}\" был удален", + "edit": "Редактировать", "getKeyFailed": "Не удалось получить ключ", "keyCopied": "Ключ скопирован в буфер обмена", "keyLoading": "Загрузка...", "officialWebsite": "Официальный", "priority": "Приоритет", + "resetCircuit": "Сбросить выключатель", "resetCircuitFailed": "Не удалось сбросить автоматический выключатель", "resetCircuitSuccess": "Автоматический выключатель сброшен", "resetCircuitSuccessDesc": "Статус автоматического выключателя провайдера \"{name}\" очищен", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index e15341510..488d85f2b 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -371,13 +371,17 @@ "users": "用户排行", "keys": "密钥排行", "userRanking": "用户排行", - "providerRanking": "供应商排行", - "providerCacheHitRateRanking": "供应商缓存命中率排行", - "modelRanking": "模型排行", - "dailyRanking": "今日", - "weeklyRanking": "本周", - "monthlyRanking": "本月", - "allTimeRanking": "全部" + "userRankingShort": "用户", + "providerRanking": "供应商排行", + "providerRankingShort": "供应商", + "providerCacheHitRateRanking": "供应商缓存命中率排行", + "providerCacheHitRateRankingShort": "缓存率", + "modelRanking": "模型排行", + "modelRankingShort": "模型", + "dailyRanking": "今日", + "weeklyRanking": "本周", + "monthlyRanking": "本月", + "allTimeRanking": "全部" }, "dateRange": { "to": "至", diff --git a/messages/zh-CN/settings/index.ts b/messages/zh-CN/settings/index.ts index 47a6b5424..aee9d4601 100644 --- a/messages/zh-CN/settings/index.ts +++ b/messages/zh-CN/settings/index.ts @@ -15,6 +15,8 @@ import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; import providersBatchEdit from "./providers/batchEdit.json"; import providersFilter from "./providers/filter.json"; +import providersGroupEdit from "./providers/groupEdit.json"; +import providersGroupPriority from "./providers/groupPriority.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; @@ -78,6 +80,8 @@ const providers = { batchEdit: providersBatchEdit, filter: providersFilter, form: providersForm, + groupEdit: providersGroupEdit, + groupPriority: providersGroupPriority, guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, diff --git a/messages/zh-CN/settings/providers/filter.json b/messages/zh-CN/settings/providers/filter.json index b1d3dcf97..11de55871 100644 --- a/messages/zh-CN/settings/providers/filter.json +++ b/messages/zh-CN/settings/providers/filter.json @@ -1,13 +1,23 @@ { - "status": { - "all": "全部状态", - "active": "已启用", - "inactive": "已禁用" - }, + "apply": "应用筛选", + "circuitBroken": "熔断", "groups": { - "label": "分组:", "all": "全部", - "default": "default" + "default": "default", + "label": "分组:" + }, + "reset": "重置", + "sort": { + "label": "排序:" + }, + "status": { + "active": "已启用", + "all": "全部状态", + "inactive": "已禁用", + "label": "状态:" }, - "circuitBroken": "熔断" + "title": "筛选选项", + "type": { + "label": "类型:" + } } diff --git a/messages/zh-CN/settings/providers/groupEdit.json b/messages/zh-CN/settings/providers/groupEdit.json new file mode 100644 index 000000000..030b60a08 --- /dev/null +++ b/messages/zh-CN/settings/providers/groupEdit.json @@ -0,0 +1,12 @@ +{ + "title": "选择分组", + "addPlaceholder": "添加新分组...", + "add": "添加", + "saveSuccess": "分组已更新", + "saveFailed": "更新分组失败", + "noGroups": "暂无可用分组", + "defaultGroup": "默认", + "invalidGroupName": "无效的分组名", + "reservedGroupName": "保留的分组名", + "commaNotAllowed": "分组名不能包含逗号" +} diff --git a/messages/zh-CN/settings/providers/groupPriority.json b/messages/zh-CN/settings/providers/groupPriority.json new file mode 100644 index 000000000..f64063272 --- /dev/null +++ b/messages/zh-CN/settings/providers/groupPriority.json @@ -0,0 +1,6 @@ +{ + "title": "分组优先级", + "default": "默认", + "emptyHint": "留空则使用默认值", + "hasOverrides": "存在分组级别的优先级覆盖" +} diff --git a/messages/zh-CN/settings/providers/list.json b/messages/zh-CN/settings/providers/list.json index a63cdf96d..9dce371f0 100644 --- a/messages/zh-CN/settings/providers/list.json +++ b/messages/zh-CN/settings/providers/list.json @@ -1,4 +1,7 @@ { + "clone": "克隆", + "delete": "删除", + "edit": "编辑", "priority": "优先级", "weight": "权重", "costMultiplier": "成本倍数", @@ -22,6 +25,7 @@ "keyCopied": "密钥已复制到剪贴板", "copyFailed": "复制失败", "clipboardUnavailable": "当前环境无法访问剪贴板,请手动选择复制。", + "resetCircuit": "重置熔断", "resetCircuitSuccess": "熔断器已重置", "resetCircuitSuccessDesc": "供应商 \"{name}\" 的熔断状态已解除", "resetCircuitFailed": "重置熔断器失败", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 9f923f14f..22b3bf224 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -371,9 +371,13 @@ "users": "使用者排名", "keys": "密鑰排名", "userRanking": "使用者排名", + "userRankingShort": "使用者", "providerRanking": "供應商排名", + "providerRankingShort": "供應商", "providerCacheHitRateRanking": "供應商快取命中率排行", + "providerCacheHitRateRankingShort": "快取率", "modelRanking": "模型排名", + "modelRankingShort": "模型", "dailyRanking": "今天", "weeklyRanking": "本週", "monthlyRanking": "當月", diff --git a/messages/zh-TW/settings/index.ts b/messages/zh-TW/settings/index.ts index 47a6b5424..aee9d4601 100644 --- a/messages/zh-TW/settings/index.ts +++ b/messages/zh-TW/settings/index.ts @@ -15,6 +15,8 @@ import strings from "./strings.json"; import providersAutoSort from "./providers/autoSort.json"; import providersBatchEdit from "./providers/batchEdit.json"; import providersFilter from "./providers/filter.json"; +import providersGroupEdit from "./providers/groupEdit.json"; +import providersGroupPriority from "./providers/groupPriority.json"; import providersGuide from "./providers/guide.json"; import providersInlineEdit from "./providers/inlineEdit.json"; import providersList from "./providers/list.json"; @@ -78,6 +80,8 @@ const providers = { batchEdit: providersBatchEdit, filter: providersFilter, form: providersForm, + groupEdit: providersGroupEdit, + groupPriority: providersGroupPriority, guide: providersGuide, inlineEdit: providersInlineEdit, list: providersList, diff --git a/messages/zh-TW/settings/providers/filter.json b/messages/zh-TW/settings/providers/filter.json index fcd227647..b0eb7a1af 100644 --- a/messages/zh-TW/settings/providers/filter.json +++ b/messages/zh-TW/settings/providers/filter.json @@ -1,13 +1,23 @@ { + "apply": "套用篩選", "circuitBroken": "熔斷", "groups": { "all": "所有", "default": "default", "label": "分組:" }, + "reset": "重置", + "sort": { + "label": "排序:" + }, "status": { "active": "已啟用", "all": "所有狀態", - "inactive": "已停用" + "inactive": "已停用", + "label": "狀態:" + }, + "title": "篩選選項", + "type": { + "label": "類型:" } } diff --git a/messages/zh-TW/settings/providers/groupEdit.json b/messages/zh-TW/settings/providers/groupEdit.json new file mode 100644 index 000000000..435fbb80e --- /dev/null +++ b/messages/zh-TW/settings/providers/groupEdit.json @@ -0,0 +1,12 @@ +{ + "title": "選擇分組", + "addPlaceholder": "新增分組...", + "add": "新增", + "saveSuccess": "分組已更新", + "saveFailed": "更新分組失敗", + "noGroups": "暫無可用分組", + "defaultGroup": "預設", + "invalidGroupName": "無效的分組名", + "reservedGroupName": "保留的分組名", + "commaNotAllowed": "分組名不能包含逗號" +} diff --git a/messages/zh-TW/settings/providers/groupPriority.json b/messages/zh-TW/settings/providers/groupPriority.json new file mode 100644 index 000000000..7c091e53b --- /dev/null +++ b/messages/zh-TW/settings/providers/groupPriority.json @@ -0,0 +1,6 @@ +{ + "title": "分組優先級", + "default": "預設", + "emptyHint": "留空則使用預設值", + "hasOverrides": "存在分組級別的優先級覆蓋" +} diff --git a/messages/zh-TW/settings/providers/list.json b/messages/zh-TW/settings/providers/list.json index bcc07e938..6d31dfb36 100644 --- a/messages/zh-TW/settings/providers/list.json +++ b/messages/zh-TW/settings/providers/list.json @@ -1,6 +1,8 @@ { "cancelButton": "關閉", "circuitBroken": "熔斷中", + "clone": "複製", + "delete": "刪除", "clipboardUnavailable": "目前環境無法使用剪貼簿,請手動選取複製。", "confirmDeleteMessage": "確定要刪除供應商 \"{name}\" 嗎?此操作無法撤銷。", "confirmDeleteTitle": "確認刪除供應商?", @@ -11,11 +13,13 @@ "deleteFailed": "刪除失敗", "deleteSuccess": "刪除成功", "deleteSuccessDesc": "供應商 \"{name}\" 已刪除", + "edit": "編輯", "getKeyFailed": "取得金鑰失敗", "keyCopied": "金鑰已複製到剪貼簿", "keyLoading": "載入中...", "officialWebsite": "官網", "priority": "優先級", + "resetCircuit": "重置熔斷", "resetCircuitFailed": "重置熔斷器失敗", "resetCircuitSuccess": "熔斷器已重置", "resetCircuitSuccessDesc": "供應商 \"{name}\" 的熔斷狀態已解除", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 7cbd01203..f25e6419f 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -237,6 +237,7 @@ export async function getProviders(): Promise { priority: provider.priority, costMultiplier: provider.costMultiplier, groupTag: provider.groupTag, + groupPriorities: provider.groupPriorities, providerType: provider.providerType, providerVendorId: provider.providerVendorId, preserveClientIp: provider.preserveClientIp, @@ -607,6 +608,7 @@ export async function editProvider( priority?: number; cost_multiplier?: number; group_tag?: string | null; + group_priorities?: Record | null; provider_type?: ProviderType; preserve_client_ip?: boolean; model_redirects?: Record | null; diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx index e4806fdb5..4e32eff34 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/endpoint-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { Radio, RefreshCw } from "lucide-react"; +import { Radio } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx index c773a0d80..9fb386698 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx @@ -2,13 +2,8 @@ import { useTranslations } from "next-intl"; import { useMemo } from "react"; -import { CartesianGrid, Line, LineChart, ResponsiveContainer, XAxis, YAxis } from "recharts"; -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { cn } from "@/lib/utils"; import type { ProviderEndpointProbeLog } from "@/types/provider"; diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx index b31213083..6799e66fa 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertCircle, CheckCircle2, Download, Trash2, XCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, XCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -77,7 +77,7 @@ export function ProbeTerminal({ if (autoScroll && !userScrolled && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } - }, [logs, autoScroll, userScrolled]); + }, [autoScroll, userScrolled]); // Detect user scroll const handleScroll = () => { @@ -183,7 +183,7 @@ export function ProbeTerminal({ filteredLogs.map((log) => { const level = getLogLevel(log); const config = levelConfig[level]; - const Icon = config.icon; + const _Icon = config.icon; return ( + )} + + {selectedLog && ( + { + if (!open) setSelectedLog(null); + }} + /> + )} + + ); +} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 16f7f246b..716e769b9 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -1,7 +1,7 @@ "use client"; import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Expand, Filter, ListOrdered, Minimize2, Pause, Play, RefreshCw } from "lucide-react"; +import { Expand, Minimize2, Pause, Play, RefreshCw } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -11,17 +11,15 @@ import type { OverviewData } from "@/actions/overview"; import { getOverviewData } from "@/actions/overview"; import { getProviders } from "@/actions/providers"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { useFullscreen } from "@/hooks/use-fullscreen"; -import { getHiddenColumns, type LogsTableColumn } from "@/lib/column-visibility"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource, SystemSettings } from "@/types/system-config"; import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; -import { ColumnVisibilityDropdown } from "./column-visibility-dropdown"; import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table"; @@ -85,13 +83,6 @@ function UsageLogsViewContent({ const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const [hideProviderColumn, setHideProviderColumn] = useState(false); const wasInFullscreenRef = useRef(false); - const [hiddenColumns, setHiddenColumns] = useState([]); - - // Load initial hidden columns from localStorage - useEffect(() => { - const stored = getHiddenColumns(userId, "usage-logs"); - setHiddenColumns(stored); - }, [userId]); const resetFullscreenState = useCallback(() => { setIsFullscreenOpen(false); @@ -267,8 +258,7 @@ function UsageLogsViewContent({ return ( <> -
- {/* Stats Summary - Collapsible */} +
- {/* Filter Criteria */} - - -
-
- -
-
- {t("title.filterCriteria")} - - {t("title.filterCriteriaDescription")} - -
-
+ + + {t("title.filterCriteria")} - + - {/* Usage Records Table */} - - -
-
-
- -
-
- {t("title.usageLogs")} - - {t("title.usageLogsDescription")} - -
-
-
- - + + +
+ {t("title.usageLogs")} +
@@ -352,13 +313,10 @@ function UsageLogsViewContent({ variant="outline" size="sm" onClick={handleManualRefresh} - className="gap-1.5 h-8" + className="gap-2" disabled={isFullscreenOpen} - aria-label={t("logs.actions.refresh")} > - + {t("logs.actions.refresh")} @@ -366,22 +324,17 @@ function UsageLogsViewContent({ variant={isAutoRefresh ? "default" : "outline"} size="sm" onClick={() => setIsAutoRefresh(!isAutoRefresh)} - className="gap-1.5 h-8" + className="gap-2" disabled={isFullscreenOpen} - aria-label={ - isAutoRefresh - ? t("logs.actions.stopAutoRefresh") - : t("logs.actions.startAutoRefresh") - } > {isAutoRefresh ? ( <> - + {t("logs.actions.stopAutoRefresh")} ) : ( <> - + {t("logs.actions.startAutoRefresh")} )} @@ -389,14 +342,13 @@ function UsageLogsViewContent({
- +
diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index a7cba3df7..867c40fbb 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { RelativeTime } from "@/components/ui/relative-time"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useVirtualizer } from "@/hooks/use-virtualizer"; +import { useIsMobile } from "@/lib/hooks/use-mobile"; import { cn, formatTokenAmount } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; @@ -23,6 +24,7 @@ import { } from "@/lib/utils/performance-formatter"; import type { BillingModelSource } from "@/types/system-config"; import { ErrorDetailsDialog } from "./error-details-dialog"; +import { MobileLogsList } from "./mobile-logs-list"; import { ModelDisplayWithRedirect } from "./model-display-with-redirect"; import { ProviderChainPopover } from "./provider-chain-popover"; @@ -79,6 +81,7 @@ export function VirtualizedLogsTable({ const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); + const isMobile = useIsMobile(); const hideProviderColumn = hiddenColumns?.includes("provider") ?? false; const hideUserColumn = hiddenColumns?.includes("user") ?? false; @@ -191,6 +194,22 @@ export function VirtualizedLogsTable({ return
{t("logs.table.noData")}
; } + // Render mobile card layout on small screens + if (isMobile) { + return ( + + ); + } + return (
{/* Status bar */} diff --git a/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx b/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx index 1535a6d5b..45fd598b4 100644 --- a/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx +++ b/src/app/[locale]/settings/notifications/_components/global-settings-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bell, Power } from "lucide-react"; +import { Bell } from "lucide-react"; import { useTranslations } from "next-intl"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; diff --git a/src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx b/src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx index 5f97c8e4c..da5916b21 100644 --- a/src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx +++ b/src/app/[locale]/settings/notifications/_components/webhook-targets-section.tsx @@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import type { ClientActionResult, WebhookTargetCreateInput, diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index b39d8cffb..5250404ca 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -502,7 +502,6 @@ export function ProviderForm({ hideWebsiteUrl = false, preset, urlResolver, - allowedProviderTypes, }: ProviderFormProps) { const [groupSuggestions, setGroupSuggestions] = useState([]); const [autoUrlPending, setAutoUrlPending] = useState(false); diff --git a/src/app/[locale]/settings/providers/_components/group-priority-popover.tsx b/src/app/[locale]/settings/providers/_components/group-priority-popover.tsx new file mode 100644 index 000000000..18b169cf6 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/group-priority-popover.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { Layers, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import type * as React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +export interface GroupPriorityPopoverProps { + priority: number; + groupPriorities: Record | null; + groupTag: string | null; + onSave: (priority: number, groupPriorities: Record | null) => Promise; + disabled?: boolean; + displayPriority?: number; +} + +interface GroupPriorityDraft { + default: string; + groups: Record; +} + +function parseGroupTag(groupTag: string | null): string[] { + if (!groupTag) return []; + return groupTag + .split(",") + .map((g) => g.trim()) + .filter(Boolean); +} + +function validatePriority(value: string): boolean { + if (value.length === 0) return true; // Empty is valid (means use default) + const num = Number(value); + return Number.isFinite(num) && Number.isInteger(num) && num >= 0 && num <= 2147483647; +} + +export function GroupPriorityPopover({ + priority, + groupPriorities, + groupTag, + onSave, + disabled = false, + displayPriority, +}: GroupPriorityPopoverProps) { + const t = useTranslations("settings.providers.inlineEdit"); + const tGroupPriority = useTranslations("settings.providers.groupPriority"); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + + const groups = useMemo(() => parseGroupTag(groupTag), [groupTag]); + const hasMultipleGroups = groups.length > 1; + + const initialDraft = useMemo((): GroupPriorityDraft => { + const groupsDraft: Record = {}; + for (const group of groups) { + const value = groupPriorities?.[group]; + groupsDraft[group] = value !== undefined ? value.toString() : ""; + } + return { + default: priority.toString(), + groups: groupsDraft, + }; + }, [priority, groupPriorities, groups]); + + const [draft, setDraft] = useState(initialDraft); + const firstInputRef = useRef(null); + + // Validation + const validationErrors = useMemo(() => { + const errors: Record = {}; + errors.default = !validatePriority(draft.default) || draft.default.length === 0; + for (const group of groups) { + errors[group] = !validatePriority(draft.groups[group] || ""); + } + return errors; + }, [draft, groups]); + + const hasErrors = Object.values(validationErrors).some(Boolean); + + // Focus first input when opened + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => { + firstInputRef.current?.focus(); + firstInputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [open]); + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (disabled && nextOpen) return; + + if (nextOpen) { + setDraft(initialDraft); + } else { + setSaving(false); + } + + setOpen(nextOpen); + }; + + const handleCancel = () => { + setDraft(initialDraft); + setOpen(false); + }; + + const handleSave = async () => { + if (hasErrors || saving) return; + + setSaving(true); + try { + const newPriority = Number(draft.default); + + // Build new groupPriorities + let newGroupPriorities: Record | null = null; + for (const group of groups) { + const value = draft.groups[group]?.trim(); + if (value && value.length > 0) { + if (!newGroupPriorities) newGroupPriorities = {}; + newGroupPriorities[group] = Number(value); + } + } + + const ok = await onSave(newPriority, newGroupPriorities); + if (ok) { + setOpen(false); + } + } finally { + setSaving(false); + } + }; + + const handleDefaultChange = (value: string) => { + setDraft((prev) => ({ ...prev, default: value })); + }; + + const handleGroupChange = (group: string, value: string) => { + setDraft((prev) => ({ + ...prev, + groups: { ...prev.groups, [group]: value }, + })); + }; + + // Calculate display value (show priority with indicator if has overrides) + const hasOverrides = groupPriorities && Object.keys(groupPriorities).length > 0; + + return ( + + + + + + +
+
{tGroupPriority("title")}
+ + {/* Default priority row */} +
+ {tGroupPriority("default")} + handleDefaultChange(e.target.value)} + disabled={disabled || saving} + className={cn( + "w-20 tabular-nums text-right", + validationErrors.default && "border-destructive" + )} + type="number" + inputMode="numeric" + step="1" + min="0" + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> +
+ + {/* Group priority rows */} + {hasMultipleGroups && groups.length > 0 && ( + <> + + {groups.map((group) => ( +
+ + {group} + + handleGroupChange(group, e.target.value)} + disabled={disabled || saving} + placeholder={draft.default} + className={cn( + "w-20 tabular-nums text-right", + validationErrors[group] && "border-destructive" + )} + type="number" + inputMode="numeric" + step="1" + min="0" + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> +
+ ))} +

{tGroupPriority("emptyHint")}

+ + )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx b/src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx new file mode 100644 index 000000000..6b2f2cb36 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { Loader2, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import type * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; + +export interface GroupTagEditPopoverProps { + groupTag: string | null; + availableGroups: string[]; + onSave: (groupTag: string | null) => Promise; + disabled?: boolean; +} + +export function GroupTagEditPopover({ + groupTag, + availableGroups, + onSave, + disabled = false, +}: GroupTagEditPopoverProps) { + const t = useTranslations("settings.providers.groupEdit"); + const tInline = useTranslations("settings.providers.inlineEdit"); + const [open, setOpen] = useState(false); + const [selectedGroups, setSelectedGroups] = useState>(new Set()); + const [newGroup, setNewGroup] = useState(""); + const [saving, setSaving] = useState(false); + + const inputRef = useRef(null); + const initialGroupsRef = useRef>(new Set()); + + const currentGroups = groupTag + ? groupTag + .split(",") + .map((g) => g.trim()) + .filter(Boolean) + : []; + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open]); + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (disabled && nextOpen) return; + + if (nextOpen) { + const groups = new Set(currentGroups); + initialGroupsRef.current = new Set(groups); + setSelectedGroups(groups); + setNewGroup(""); + } else { + setSaving(false); + } + + setOpen(nextOpen); + }; + + const handleCancel = () => { + setSelectedGroups(new Set(initialGroupsRef.current)); + setNewGroup(""); + setOpen(false); + }; + + const handleToggleGroup = (group: string) => { + setSelectedGroups((prev) => { + const next = new Set(prev); + if (next.has(group)) { + next.delete(group); + } else { + next.add(group); + } + return next; + }); + }; + + const handleAddGroup = () => { + const trimmed = newGroup.trim(); + if (!trimmed) return; + + // Validate: no comma allowed + if (trimmed.includes(",")) { + toast.error(t("commaNotAllowed")); + return; + } + + // Validate: reserved names (case-insensitive) + const lowerTrimmed = trimmed.toLowerCase(); + if (lowerTrimmed === PROVIDER_GROUP.DEFAULT || lowerTrimmed === PROVIDER_GROUP.ALL) { + toast.error(t("reservedGroupName")); + return; + } + + // Validate: no duplicates + if (selectedGroups.has(trimmed)) { + return; + } + + setSelectedGroups((prev) => new Set(prev).add(trimmed)); + setNewGroup(""); + }; + + const handleSave = async () => { + setSaving(true); + try { + const result = selectedGroups.size > 0 ? Array.from(selectedGroups).join(",") : null; + const ok = await onSave(result); + if (ok) { + setOpen(false); + } + } finally { + setSaving(false); + } + }; + + const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( + (g) => g !== "default" + ); + + return ( + + + + + + +
+
{t("title")}
+ +
+ {allDisplayGroups.length > 0 ? ( + allDisplayGroups.map((group) => ( + + )) + ) : ( +
{t("noGroups")}
+ )} +
+ +
+ setNewGroup(e.target.value)} + placeholder={t("addPlaceholder")} + disabled={saving} + className="flex-1 h-8 text-sm" + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + handleAddGroup(); + } + }} + /> + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-list.tsx b/src/app/[locale]/settings/providers/_components/provider-list.tsx index 7feb92b2b..db653719a 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list.tsx @@ -26,6 +26,8 @@ interface ProviderListProps { isMultiSelectMode?: boolean; selectedProviderIds?: Set; onSelectProvider?: (providerId: number, checked: boolean) => void; + selectedGroup?: string | null; + availableGroups?: string[]; } export function ProviderList({ @@ -39,6 +41,8 @@ export function ProviderList({ isMultiSelectMode = false, selectedProviderIds = new Set(), onSelectProvider, + selectedGroup = null, + availableGroups = [], }: ProviderListProps) { const t = useTranslations("settings.providers"); @@ -71,6 +75,8 @@ export function ProviderList({ onSelectChange={ onSelectProvider ? (checked) => onSelectProvider(provider.id, checked) : undefined } + selectedGroup={selectedGroup} + availableGroups={availableGroups} /> ))}
diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index c35a1fd93..22d22608f 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -1,7 +1,8 @@ "use client"; -import { AlertTriangle, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react"; +import { AlertTriangle, Filter, LayoutGrid, LayoutList, Loader2, Search, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,6 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { useDebounce } from "@/lib/hooks/use-debounce"; @@ -78,6 +80,9 @@ export function ProviderManager({ const [groupFilter, setGroupFilter] = useState([]); const [circuitBrokenFilter, setCircuitBrokenFilter] = useState(false); + // Mobile filter sheet state + const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + // Batch edit state const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); const [selectedProviderIds, setSelectedProviderIds] = useState>(new Set()); @@ -96,6 +101,28 @@ export function ProviderManager({ } }, [circuitBrokenCount, circuitBrokenFilter]); + // Count active filters for mobile badge + const activeFilterCount = useMemo(() => { + let count = 0; + if (viewMode !== "list") count++; + if (typeFilter !== "all") count++; + if (statusFilter !== "all") count++; + if (sortBy !== "priority") count++; + if (groupFilter.length > 0) count++; + if (circuitBrokenFilter) count++; + return count; + }, [viewMode, typeFilter, statusFilter, sortBy, groupFilter.length, circuitBrokenFilter]); + + // Reset all filters + const handleResetFilters = useCallback(() => { + setViewMode("list"); + setTypeFilter("all"); + setStatusFilter("all"); + setSortBy("priority"); + setGroupFilter([]); + setCircuitBrokenFilter(false); + }, []); + // Extract unique groups from all providers const allGroups = useMemo(() => { const groups = new Set(); @@ -286,7 +313,206 @@ export function ProviderManager({
{/* 筛选条件 */}
-
+ {/* Mobile Filter Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + disabled={loading} + /> +
+ +
+ + {/* Mobile Group Filter - Quick Access */} + {allGroups.length > 0 && ( +
+ + {allGroups.map((group) => ( + + ))} +
+ )} + + {/* Mobile Filter Sheet */} + + + + {tFilter("title")} + +
+ {/* View Mode */} +
+ +
+ + +
+
+ + {/* Type Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+ + {/* Sort */} +
+ + +
+ + {/* Group Filter */} + {allGroups.length > 0 && ( +
+ +
+ + {allGroups.map((group) => ( + + ))} +
+
+ )} + + {/* Circuit Breaker Filter */} + {circuitBrokenCount > 0 && ( +
+
+ + +
+ +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ + {/* Desktop Filter Bar */} +
{/* View Mode Toggle */}
- {/* Group filter */} + {/* Group filter (hidden on mobile - shown in Sheet) */} {allGroups.length > 0 && ( -
+
{tFilter("groups.label")} + + + + + {tList("edit")} + + + + {tList("clone")} + + {healthStatus && healthStatus.circuitState === "open" && ( + + + {tList("resetCircuit")} + + )} + {provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && ( + + + {tList("resetUsageTitle")} + + )} + + + + {tList("delete")} + + + + )} +
+
+
+ + {/* Desktop Layout */} +
{/* 多选模式下显示 checkbox */} {isMultiSelectMode && ( {provider.name} {/* Group Tags (supports comma-separated values) */} - {(provider.groupTag - ? provider.groupTag - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : [] - ).length > 0 ? ( + {canEdit ? ( + + ) : (provider.groupTag + ? provider.groupTag + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [] + ).length > 0 ? ( provider.groupTag ?.split(",") .map((t) => t.trim()) @@ -502,15 +811,25 @@ export function ProviderRichListItem({
{tList("priority")}
{canEdit ? ( - + hasMultipleGroups ? ( + + ) : ( + + ) ) : ( - {provider.priority} + {displayPriority} )}
@@ -686,43 +1005,93 @@ export function ProviderRichListItem({
- {/* 编辑 Dialog */} - - - - { - setOpenEdit(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); - }} - enableMultiProviderTypes={enableMultiProviderTypes} - /> - - - + {/* Edit Modal - Sheet on mobile, Dialog on desktop */} + {isMobile ? ( + + + + {tList("edit")} + +
+ + { + setOpenEdit(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + +
+
+
+ ) : ( + + + + { + setOpenEdit(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + + + + )} - {/* 克隆 Dialog */} - - - - { - setOpenClone(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); - }} - enableMultiProviderTypes={enableMultiProviderTypes} - /> - - - + {/* Clone Modal - Sheet on mobile, Dialog on desktop */} + {isMobile ? ( + + + + {tList("clone")} + +
+ + { + setOpenClone(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + +
+
+
+ ) : ( + + + + { + setOpenClone(false); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + router.refresh(); + }} + enableMultiProviderTypes={enableMultiProviderTypes} + /> + + + + )} {/* API Key 展示 Dialog */} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 3cc83cb87..d60e53fd3 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -2,7 +2,7 @@ import { STATUS_CODES } from "node:http"; import type { Readable } from "node:stream"; import { createGunzip, constants as zlibConstants } from "node:zlib"; import type { Dispatcher } from "undici"; -import { Agent, request as undiciRequest } from "undici"; +import { request as undiciRequest } from "undici"; import { getCircuitState, getProviderHealthInfo, @@ -16,11 +16,7 @@ import { PROVIDER_DEFAULTS, PROVIDER_LIMITS } from "@/lib/constants/provider.con import { recordEndpointFailure, recordEndpointSuccess } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; import { getPreferredProviderEndpoints } from "@/lib/provider-endpoints/endpoint-selector"; -import { - getGlobalAgentPool, - getProxyAgentForProvider, - type ProxyConfigWithCacheKey, -} from "@/lib/proxy-agent"; +import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent"; import { SessionManager } from "@/lib/session-manager"; import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes"; import { diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 834df72d6..71c701e56 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -51,6 +51,37 @@ function parseGroupString(groupString: string): string[] { .filter(Boolean); } +/** + * Get effective priority for a provider in the context of user's group + * + * Group-level priority overrides allow providers to have different priorities + * for different user groups. For example, a provider might be high priority + * for "cli" group but low priority for "chat" group. + * + * @param provider - The provider to get priority for + * @param userGroup - The user's group string (may be comma-separated) + * @returns The effective priority value + */ +function getEffectivePriority(provider: Provider, userGroup: string | null): number { + // No group or no overrides: use global priority + if (!userGroup || !provider.groupPriorities) { + return provider.priority; + } + + // Parse user groups (may be comma-separated) + const userGroups = parseGroupString(userGroup); + + // Return first matching group's priority + for (const group of userGroups) { + if (group in provider.groupPriorities) { + return provider.groupPriorities[group]; + } + } + + // No match: use global priority + return provider.priority; +} + /** * 获取有效的供应商分组(优先级:key.providerGroup > user.providerGroup) * @@ -912,12 +943,17 @@ export class ProxyProviderResolver { } // Step 5: 优先级分层(只选择最高优先级的供应商) - const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders); - const priorities = [...new Set(healthyProviders.map((p) => p.priority || 0))].sort( - (a, b) => a - b + const topPriorityProviders = ProxyProviderResolver.selectTopPriority( + healthyProviders, + effectiveGroupPick ); + const priorities = [ + ...new Set(healthyProviders.map((p) => getEffectivePriority(p, effectiveGroupPick))), + ].sort((a, b) => a - b); context.priorityLevels = priorities; - context.selectedPriority = Math.min(...healthyProviders.map((p) => p.priority || 0)); + context.selectedPriority = Math.min( + ...healthyProviders.map((p) => getEffectivePriority(p, effectiveGroupPick)) + ); // Step 6: 成本排序 + 加权选择 + 计算概率 const totalWeight = topPriorityProviders.reduce((sum, p) => sum + p.weight, 0); @@ -1034,17 +1070,19 @@ export class ProxyProviderResolver { /** * 优先级分层:只选择最高优先级的供应商 + * @param providers - 候选供应商列表 + * @param userGroup - 用户分组(用于获取分组级别的优先级覆盖) */ - private static selectTopPriority(providers: Provider[]): Provider[] { + private static selectTopPriority(providers: Provider[], userGroup: string | null): Provider[] { if (providers.length === 0) { return []; } - // 找到最小的优先级值(最高优先级) - const minPriority = Math.min(...providers.map((p) => p.priority || 0)); + // 找到最小的优先级值(最高优先级),使用分组级别的优先级覆盖 + const minPriority = Math.min(...providers.map((p) => getEffectivePriority(p, userGroup))); // 只返回该优先级的供应商 - return providers.filter((p) => (p.priority || 0) === minPriority); + return providers.filter((p) => getEffectivePriority(p, userGroup) === minPriority); } /** @@ -1183,7 +1221,10 @@ export class ProxyProviderResolver { } // 优先级分层 - const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders); + const topPriorityProviders = ProxyProviderResolver.selectTopPriority( + healthyProviders, + effectiveGroupPick + ); // 成本排序 + 加权随机选择 const selected = ProxyProviderResolver.selectOptimal(topPriorityProviders); @@ -1210,10 +1251,10 @@ export class ProxyProviderResolver { beforeHealthCheck: typeFiltered.length, afterHealthCheck: healthyProviders.length, filteredProviders: [], - priorityLevels: [...new Set(healthyProviders.map((p) => p.priority || 0))].sort( - (a, b) => a - b - ), - selectedPriority: selected.priority || 0, + priorityLevels: [ + ...new Set(healthyProviders.map((p) => getEffectivePriority(p, effectiveGroupPick))), + ].sort((a, b) => a - b), + selectedPriority: getEffectivePriority(selected, effectiveGroupPick), candidatesAtPriority: candidates, }, }; @@ -1221,4 +1262,4 @@ export class ProxyProviderResolver { } // Export for testing -export { checkProviderGroupMatch }; +export { checkProviderGroupMatch, getEffectivePriority }; diff --git a/src/components/ui/__tests__/command-tag-highlight.test.tsx b/src/components/ui/__tests__/command-tag-highlight.test.tsx index b138ec94e..bc9d04eb9 100644 --- a/src/components/ui/__tests__/command-tag-highlight.test.tsx +++ b/src/components/ui/__tests__/command-tag-highlight.test.tsx @@ -106,33 +106,29 @@ describe("TagInput suggestion highlight classes", () => { await new Promise((r) => setTimeout(r, 50)); }); - // Find suggestion buttons in the portal - const suggestionButtons = document.querySelectorAll("button.w-full.px-3.py-2"); + // Find suggestion buttons in the portal (horizontal flow layout) + const suggestionButtons = document.querySelectorAll("button.inline-flex"); // Verify suggestions are rendered expect(suggestionButtons.length).toBeGreaterThan(0); const className = suggestionButtons[0].getAttribute("class") ?? ""; - // Should use primary-based highlight classes for hover state - expect(className).toContain("hover:bg-primary"); - expect(className).toContain("hover:text-primary-foreground"); - - // Should NOT use accent-based highlight classes - expect(className).not.toContain("hover:bg-accent"); - expect(className).not.toContain("hover:text-accent-foreground"); + // Unselected suggestions use accent-based hover classes in horizontal layout + expect(className).toContain("hover:bg-accent"); unmount(); }); - test("TagInput highlighted suggestion should use primary-based background", async () => { + test("TagInput selected suggestion should use primary-based background", async () => { const suggestions = [ { value: "tag1", label: "Tag 1" }, { value: "tag2", label: "Tag 2" }, ]; + // Pre-select tag1 to verify selected state styling const { container, unmount } = render( - {}} suggestions={suggestions} /> + {}} suggestions={suggestions} /> ); // Focus the input to show suggestions @@ -145,18 +141,21 @@ describe("TagInput suggestion highlight classes", () => { await new Promise((r) => setTimeout(r, 50)); }); - // Find suggestion buttons in the portal - const suggestionButtons = document.querySelectorAll("button.w-full.px-3.py-2"); + // Find suggestion buttons in the portal (horizontal flow layout) + const suggestionButtons = document.querySelectorAll("button.inline-flex"); // Verify suggestions are rendered expect(suggestionButtons.length).toBeGreaterThan(0); - const className = suggestionButtons[0].getAttribute("class") ?? ""; + // Find the selected tag button (tag1) + const selectedButton = Array.from(suggestionButtons).find((btn) => btn.textContent === "Tag 1"); + expect(selectedButton).toBeDefined(); + + const className = selectedButton?.getAttribute("class") ?? ""; - // The highlighted state class should use primary (when index === highlightedIndex) - // This is applied conditionally, so we check the hover classes which are always present - expect(className).toContain("hover:bg-primary"); - expect(className).toContain("hover:text-primary-foreground"); + // Selected suggestions use primary-based styling + expect(className).toContain("bg-primary"); + expect(className).toContain("text-primary-foreground"); unmount(); }); diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx index 9cdb531cf..857259e8c 100644 --- a/src/components/ui/tag-input.tsx +++ b/src/components/ui/tag-input.tsx @@ -172,9 +172,9 @@ export function TagInput({ return normalizedSuggestions.filter((s) => { const keywords = s.keywords?.join(" ") || ""; const haystack = `${s.label} ${s.value} ${keywords}`.toLowerCase(); - return haystack.includes(search) && (allowDuplicates || !value.includes(s.value)); + return haystack.includes(search); }); - }, [normalizedSuggestions, inputValue, value, allowDuplicates]); + }, [normalizedSuggestions, inputValue]); // 基础验证函数(不包含默认格式校验) const validateBaseTag = React.useCallback( @@ -372,10 +372,18 @@ export function TagInput({ const handleSuggestionClick = React.useCallback( (suggestionValue: string) => { - addTag(suggestionValue, true); // keepOpen=true 保持下拉展开 + if (value.includes(suggestionValue)) { + // Already selected -> deselect (remove tag) + const nextTags = value.filter((v) => v !== suggestionValue); + onChange(nextTags); + onChangeCommit?.(nextTags); + } else { + // Not selected -> select (add tag) + addTag(suggestionValue, true); + } inputRef.current?.focus(); }, - [addTag] + [value, onChange, onChangeCommit, addTag] ); const handleClear = React.useCallback(() => { @@ -468,12 +476,12 @@ export function TagInput({ )} - {/* 建议下拉列表 - 使用 Radix Portal 确保在 Dialog 中正确渲染 */} + {/* 建议下拉列表 - 使用 Radix Portal 确保在 Dialog 中正确渲染 + 水平流式布局 */} {showSuggestions && filteredSuggestions.length > 0 && dropdownPosition && (
{ e.preventDefault(); diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index d53a7ff82..a3ff80ada 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -161,6 +161,9 @@ export const providers = pgTable('providers', { priority: integer('priority').notNull().default(0), costMultiplier: numeric('cost_multiplier', { precision: 10, scale: 4 }).default('1.0'), groupTag: varchar('group_tag', { length: 50 }), + // Group-level priority overrides: { [groupTag]: number } + // When set, provider can have different priorities for different groups + groupPriorities: jsonb('group_priorities').$type | null>().default(null), // 供应商类型:扩展支持 5 种类型 // - claude: Anthropic 提供商(标准认证) diff --git a/src/lib/hooks/use-mobile.ts b/src/lib/hooks/use-mobile.ts new file mode 100644 index 000000000..08b21a78f --- /dev/null +++ b/src/lib/hooks/use-mobile.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(undefined); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isMobile; +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index b92501375..9ec0ad008 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -553,6 +553,11 @@ export const UpdateProviderSchema = z .optional(), cost_multiplier: z.coerce.number().min(0, "成本倍率不能为负数").optional(), group_tag: z.string().max(50, "分组标签不能超过50个字符").nullable().optional(), + // Group-level priority overrides: { [groupTag]: priority } + group_priorities: z + .record(z.string().max(50), z.number().int().min(0).max(2147483647)) + .nullable() + .optional(), // Codex 支持:供应商类型和模型重定向 provider_type: z .enum(["claude", "claude-auth", "codex", "gemini", "gemini-cli", "openai-compatible"]) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 09e50ee2a..93ff617a0 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -87,6 +87,7 @@ export function toProvider(dbProvider: any): Provider { priority: dbProvider?.priority ?? 0, costMultiplier: dbProvider?.costMultiplier ? parseFloat(dbProvider.costMultiplier) : 1.0, groupTag: dbProvider?.groupTag ?? null, + groupPriorities: dbProvider?.groupPriorities ?? null, providerType: dbProvider?.providerType ?? "claude", preserveClientIp: dbProvider?.preserveClientIp ?? false, modelRedirects: dbProvider?.modelRedirects ?? null, diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 4e6376e1b..10bc21285 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -33,6 +33,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< costMultiplier: providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0", groupTag: providerData.group_tag, + groupPriorities: providerData.group_priorities ?? null, providerType: providerData.provider_type, preserveClientIp: providerData.preserve_client_ip ?? false, modelRedirects: providerData.model_redirects, @@ -88,6 +89,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< priority: providers.priority, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, + groupPriorities: providers.groupPriorities, providerType: providers.providerType, preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, @@ -168,6 +170,7 @@ export async function findProviderList( priority: providers.priority, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, + groupPriorities: providers.groupPriorities, providerType: providers.providerType, preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, @@ -244,6 +247,7 @@ export async function findAllProvidersFresh(): Promise { priority: providers.priority, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, + groupPriorities: providers.groupPriorities, providerType: providers.providerType, preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, @@ -324,6 +328,7 @@ export async function findProviderById(id: number): Promise { priority: providers.priority, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, + groupPriorities: providers.groupPriorities, providerType: providers.providerType, preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, @@ -396,6 +401,8 @@ export async function updateProvider( dbData.costMultiplier = providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0"; if (providerData.group_tag !== undefined) dbData.groupTag = providerData.group_tag; + if (providerData.group_priorities !== undefined) + dbData.groupPriorities = providerData.group_priorities; if (providerData.provider_type !== undefined) dbData.providerType = providerData.provider_type; if (providerData.preserve_client_ip !== undefined) dbData.preserveClientIp = providerData.preserve_client_ip; @@ -511,6 +518,7 @@ export async function updateProvider( priority: providers.priority, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, + groupPriorities: providers.groupPriorities, providerType: providers.providerType, preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, diff --git a/src/types/provider.ts b/src/types/provider.ts index dd961e79c..a74cb1030 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -52,6 +52,8 @@ export interface Provider { priority: number; costMultiplier: number; groupTag: string | null; + // Group-level priority overrides: { [groupTag]: number } + groupPriorities: Record | null; // 供应商类型:扩展支持 4 种类型 providerType: ProviderType; @@ -155,6 +157,8 @@ export interface ProviderDisplay { priority: number; costMultiplier: number; groupTag: string | null; + // Group-level priority overrides: { [groupTag]: number } + groupPriorities: Record | null; // 供应商类型 providerType: ProviderType; // 供应商聚合实体(按官网域名归一) @@ -245,6 +249,7 @@ export interface CreateProviderData { priority?: number; cost_multiplier?: number; group_tag?: string | null; + group_priorities?: Record | null; // 供应商类型和模型配置 provider_type?: ProviderType; @@ -315,6 +320,7 @@ export interface UpdateProviderData { priority?: number; cost_multiplier?: number; group_tag?: string | null; + group_priorities?: Record | null; // 供应商类型和模型配置 provider_type?: ProviderType; diff --git a/tests/unit/proxy/provider-selector-group-priority.test.ts b/tests/unit/proxy/provider-selector-group-priority.test.ts new file mode 100644 index 000000000..ed94f58a5 --- /dev/null +++ b/tests/unit/proxy/provider-selector-group-priority.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { getEffectivePriority } from "@/app/v1/_lib/proxy/provider-selector"; +import type { Provider } from "@/types/provider"; + +// Helper to create a minimal Provider for testing +function createProvider( + priority: number, + groupPriorities: Record | null = null +): Provider { + return { + id: 1, + name: "test-provider", + providerType: "claude", + url: "https://api.anthropic.com", + apiKey: "test-key", + enabled: true, + priority, + weight: 1, + costMultiplier: 1, + groupTag: null, + allowedModels: null, + modelRedirects: null, + joinClaudePool: false, + groupPriorities, + providerVendorId: null, + limitTotalUsd: null, + usedTotalUsd: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +describe("getEffectivePriority - Group-level priority override", () => { + describe("When user has no group", () => { + test("should return default priority when userGroup is null", () => { + const provider = createProvider(5, { cli: 1, chat: 3 }); + expect(getEffectivePriority(provider, null)).toBe(5); + }); + + test("should return default priority when userGroup is empty string", () => { + const provider = createProvider(5, { cli: 1 }); + expect(getEffectivePriority(provider, "")).toBe(5); + }); + }); + + describe("When provider has no group overrides", () => { + test("should return default priority when groupPriorities is null", () => { + const provider = createProvider(5, null); + expect(getEffectivePriority(provider, "cli")).toBe(5); + }); + + test("should return default priority when groupPriorities is empty", () => { + const provider = createProvider(5, {}); + expect(getEffectivePriority(provider, "cli")).toBe(5); + }); + }); + + describe("When user group matches override", () => { + test("should return group-specific priority when user group matches", () => { + const provider = createProvider(5, { cli: 1, chat: 3 }); + expect(getEffectivePriority(provider, "cli")).toBe(1); + expect(getEffectivePriority(provider, "chat")).toBe(3); + }); + + test("should return first matching group priority for comma-separated groups", () => { + const provider = createProvider(5, { cli: 1, chat: 3 }); + // First match wins: "chat" comes first, so priority 3 is returned + expect(getEffectivePriority(provider, "chat,cli")).toBe(3); + // First match wins: "cli" comes first, so priority 1 is returned + expect(getEffectivePriority(provider, "cli,chat")).toBe(1); + }); + + test("should handle whitespace in comma-separated groups", () => { + const provider = createProvider(5, { cli: 1 }); + expect(getEffectivePriority(provider, " cli , chat ")).toBe(1); + }); + }); + + describe("When user group does not match any override", () => { + test("should return default priority when no group override exists", () => { + const provider = createProvider(5, { cli: 1 }); + expect(getEffectivePriority(provider, "web")).toBe(5); + }); + + test("should return default priority when none of comma-separated groups match", () => { + const provider = createProvider(5, { cli: 1 }); + expect(getEffectivePriority(provider, "web,mobile")).toBe(5); + }); + }); + + describe("Edge cases", () => { + test("should handle priority value of 0", () => { + const provider = createProvider(5, { cli: 0 }); + expect(getEffectivePriority(provider, "cli")).toBe(0); + }); + + test("should handle very high priority values", () => { + const provider = createProvider(5, { cli: 2147483647 }); + expect(getEffectivePriority(provider, "cli")).toBe(2147483647); + }); + + test("should be case-sensitive for group names", () => { + const provider = createProvider(5, { CLI: 1 }); + expect(getEffectivePriority(provider, "cli")).toBe(5); // No match, returns default + expect(getEffectivePriority(provider, "CLI")).toBe(1); // Exact match + }); + }); +});