Преглед на файлове

feat: add individual request tracking within sessions

- Add requestSequence column to messageRequest table with composite index
- Implement atomic sequence generation via Redis INCR
- Update Redis key pattern to session:{id}:req:{seq}:messages/response
- Add backward compatibility fallback for legacy Redis keys
- Create getSessionRequests action with pagination support
- Add collapsible request list sidebar to session detail page
- Show request sequence badge in usage logs and error dialog
- Support URL-based state via ?seq=N query parameter
- Add i18n translations for 5 languages (en, zh-CN, ja, zh-TW, ru)

Closes the issue where multiple requests in a 5-min session
overwrote each other's messages/response data.
ding113 преди 2 месеца
родител
ревизия
e908d3ccef

+ 2 - 0
drizzle/0032_add_request_sequence.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "message_request" ADD COLUMN "request_sequence" integer DEFAULT 1;--> statement-breakpoint
+CREATE INDEX "idx_message_request_session_seq" ON "message_request" USING btree ("session_id","request_sequence") WHERE "message_request"."deleted_at" IS NULL;

+ 1882 - 0
drizzle/meta/0032_snapshot.json

@@ -0,0 +1,1882 @@
+{
+  "id": "ef85f4a3-1524-4e95-a89c-b1d0e0be7823",
+  "prevId": "2a30cb64-6325-46c7-a147-9758de5c3a00",
+  "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(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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
+        },
+        "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
+        },
+        "error_message": {
+          "name": "error_message",
+          "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_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_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
+        },
+        "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": {}
+        }
+      },
+      "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
+        },
+        "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.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
+        },
+        "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
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "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_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
+        },
+        "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": {}
+        }
+      },
+      "foreignKeys": {},
+      "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
+        },
+        "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": {}
+        }
+      },
+      "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
+        },
+        "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,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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
+        },
+        "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
+        },
+        "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
+    }
+  },
+  "enums": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -225,6 +225,13 @@
       "when": 1765095940032,
       "tag": "0031_rare_roxanne_simpson",
       "breakpoints": true
+    },
+    {
+      "idx": 32,
+      "version": "7",
+      "when": 1765114544111,
+      "tag": "0032_add_request_sequence",
+      "breakpoints": true
     }
   ]
 }

+ 8 - 0
messages/en/dashboard.json

@@ -361,6 +361,14 @@
     "errors": {
       "copyFailed": "Copy Failed"
     },
+    "requestList": {
+      "title": "Requests",
+      "noRequests": "No requests found",
+      "fetchFailed": "Failed to fetch requests",
+      "unknownError": "Unknown error occurred",
+      "prev": "Prev",
+      "next": "Next"
+    },
     "back": "Back",
     "loadingError": "Loading failed"
   },

+ 8 - 0
messages/ja/dashboard.json

@@ -360,6 +360,14 @@
     "errors": {
       "copyFailed": "コピー失敗"
     },
+    "requestList": {
+      "title": "リクエスト一覧",
+      "noRequests": "リクエストがありません",
+      "fetchFailed": "リクエスト一覧の取得に失敗しました",
+      "unknownError": "不明なエラー",
+      "prev": "前へ",
+      "next": "次へ"
+    },
     "back": "戻る",
     "loadingError": "読み込み失敗"
   },

+ 8 - 0
messages/ru/dashboard.json

@@ -360,6 +360,14 @@
     "errors": {
       "copyFailed": "Не удалось скопировать"
     },
+    "requestList": {
+      "title": "Список запросов",
+      "noRequests": "Запросы не найдены",
+      "fetchFailed": "Не удалось получить список запросов",
+      "unknownError": "Неизвестная ошибка",
+      "prev": "Назад",
+      "next": "Вперед"
+    },
     "back": "Назад",
     "loadingError": "Не удалось загрузить"
   },

+ 8 - 0
messages/zh-CN/dashboard.json

@@ -361,6 +361,14 @@
     "errors": {
       "copyFailed": "复制失败"
     },
+    "requestList": {
+      "title": "请求列表",
+      "noRequests": "暂无请求记录",
+      "fetchFailed": "获取请求列表失败",
+      "unknownError": "未知错误",
+      "prev": "上一页",
+      "next": "下一页"
+    },
     "back": "返回",
     "loadingError": "加载失败"
   },

+ 8 - 0
messages/zh-TW/dashboard.json

@@ -361,6 +361,14 @@
     "errors": {
       "copyFailed": "複製失敗"
     },
+    "requestList": {
+      "title": "請求列表",
+      "noRequests": "暫無請求記錄",
+      "fetchFailed": "取得請求列表失敗",
+      "unknownError": "未知錯誤",
+      "prev": "上一頁",
+      "next": "下一頁"
+    },
     "back": "返回",
     "loadingError": "載入失敗"
   },

+ 107 - 7
src/actions/active-sessions.ts

@@ -373,19 +373,27 @@ export async function hasSessionMessages(sessionId: string): Promise<ActionResul
 }
 
 /**
- * 获取 session 的完整详情(messages + response + 聚合统计)
- * 用于 session messages 详情页面
+ * 获取 Session 详情(包括 messages 和 response)
+ *
+ * 功能:获取指定 Session 的消息内容和响应数据
+ * 权限:管理员可查看所有 Session,普通用户只能查看自己的 Session
+ *
+ * @param sessionId - Session ID
+ * @param requestSequence - 请求序号(可选,用于获取 Session 内特定请求的消息)
  *
- * 优化:添加缓存支持
  * 安全修复:添加用户权限检查
  */
-export async function getSessionDetails(sessionId: string): Promise<
+export async function getSessionDetails(
+  sessionId: string,
+  requestSequence?: number
+): Promise<
   ActionResult<{
     messages: unknown | null;
     response: string | null;
     sessionStats: Awaited<
       ReturnType<typeof import("@/repository/message").aggregateSessionStats>
     > | null;
+    currentSequence: number | null;
   }>
 > {
   try {
@@ -444,9 +452,10 @@ export async function getSessionDetails(sessionId: string): Promise<
 
     // 5. 并行获取 messages 和 response(不缓存,因为这些数据较大)
     const { SessionManager } = await import("@/lib/session-manager");
-    const [messages, response] = await Promise.all([
-      SessionManager.getSessionMessages(sessionId),
-      SessionManager.getSessionResponse(sessionId),
+    const [messages, response, requestCount] = await Promise.all([
+      SessionManager.getSessionMessages(sessionId, requestSequence),
+      SessionManager.getSessionResponse(sessionId, requestSequence),
+      SessionManager.getSessionRequestCount(sessionId),
     ]);
 
     return {
@@ -455,6 +464,7 @@ export async function getSessionDetails(sessionId: string): Promise<
         messages,
         response,
         sessionStats,
+        currentSequence: requestSequence ?? (requestCount > 0 ? requestCount : null),
       },
     };
   } catch (error) {
@@ -466,6 +476,96 @@ export async function getSessionDetails(sessionId: string): Promise<
   }
 }
 
+/**
+ * 获取 Session 内的所有请求列表(分页)
+ *
+ * 功能:获取指定 Session 中的所有请求记录,用于 Session 详情页的请求列表侧边栏
+ * 权限:管理员可查看所有 Session,普通用户只能查看自己的 Session
+ *
+ * @param sessionId - Session ID
+ * @param page - 页码(从 1 开始)
+ * @param pageSize - 每页数量(默认 20)
+ */
+export async function getSessionRequests(
+  sessionId: string,
+  page: number = 1,
+  pageSize: number = 20
+): Promise<
+  ActionResult<{
+    requests: Array<{
+      id: number;
+      sequence: number;
+      model: string | null;
+      statusCode: number | null;
+      costUsd: string | null;
+      createdAt: Date | null;
+      inputTokens: number | null;
+      outputTokens: number | null;
+      errorMessage: string | null;
+    }>;
+    total: number;
+    hasMore: boolean;
+  }>
+> {
+  try {
+    // 0. 验证用户权限
+    const authSession = await getSession();
+    if (!authSession) {
+      return {
+        ok: false,
+        error: "未登录",
+      };
+    }
+
+    const isAdmin = authSession.user.role === "admin";
+    const currentUserId = authSession.user.id;
+
+    // 1. 验证 Session 所有权
+    const { aggregateSessionStats } = await import("@/repository/message");
+    const sessionStats = await aggregateSessionStats(sessionId);
+
+    if (!sessionStats) {
+      return {
+        ok: false,
+        error: "Session 不存在",
+      };
+    }
+
+    if (!isAdmin && sessionStats.userId !== currentUserId) {
+      logger.warn(
+        `[Security] User ${currentUserId} attempted to access session requests ${sessionId} owned by user ${sessionStats.userId}`
+      );
+      return {
+        ok: false,
+        error: "无权访问该 Session",
+      };
+    }
+
+    // 2. 查询请求列表
+    const { findRequestsBySessionId } = await import("@/repository/message");
+    const offset = (page - 1) * pageSize;
+    const { requests, total } = await findRequestsBySessionId(sessionId, {
+      limit: pageSize,
+      offset,
+    });
+
+    return {
+      ok: true,
+      data: {
+        requests,
+        total,
+        hasMore: offset + requests.length < total,
+      },
+    };
+  } catch (error) {
+    logger.error("Failed to get session requests:", error);
+    return {
+      ok: false,
+      error: "获取 Session 请求列表失败",
+    };
+  }
+}
+
 /**
  * 终止活跃 Session(主动打断)
  *

+ 17 - 2
src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx

@@ -25,6 +25,7 @@ interface ErrorDetailsDialogProps {
   errorMessage: string | null;
   providerChain: ProviderChainItem[] | null;
   sessionId: string | null;
+  requestSequence?: number | null; // Request Sequence(Session 内请求序号)
   blockedBy?: string | null; // 拦截类型
   blockedReason?: string | null; // 拦截原因(JSON 字符串)
   originalModel?: string | null; // 原始模型(重定向前)
@@ -43,6 +44,7 @@ export function ErrorDetailsDialog({
   errorMessage,
   providerChain,
   sessionId,
+  requestSequence,
   blockedBy,
   blockedReason,
   originalModel,
@@ -269,10 +271,23 @@ export function ErrorDetailsDialog({
               <h4 className="font-semibold text-sm">{t("logs.details.sessionId")}</h4>
               <div className="flex items-center gap-3">
                 <div className="flex-1 rounded-md border bg-muted/50 p-3">
-                  <code className="text-xs font-mono break-all">{sessionId}</code>
+                  <div className="flex items-center gap-2">
+                    <code className="text-xs font-mono break-all">{sessionId}</code>
+                    {requestSequence && (
+                      <Badge variant="outline" className="text-xs shrink-0">
+                        #{requestSequence}
+                      </Badge>
+                    )}
+                  </div>
                 </div>
                 {hasMessages && !checkingMessages && (
-                  <Link href={`/dashboard/sessions/${sessionId}/messages`}>
+                  <Link
+                    href={
+                      requestSequence
+                        ? `/dashboard/sessions/${sessionId}/messages?seq=${requestSequence}`
+                        : `/dashboard/sessions/${sessionId}/messages`
+                    }
+                  >
                     <Button variant="outline" size="sm">
                       <ExternalLink className="h-4 w-4 mr-2" />
                       {t("logs.details.viewDetails")}

+ 1 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -273,6 +273,7 @@ export function UsageLogsTable({
                         errorMessage={log.errorMessage}
                         providerChain={log.providerChain}
                         sessionId={log.sessionId}
+                        requestSequence={log.requestSequence}
                         blockedBy={log.blockedBy}
                         blockedReason={log.blockedReason}
                         originalModel={log.originalModel}

+ 255 - 0
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx

@@ -0,0 +1,255 @@
+"use client";
+
+import {
+  AlertCircle,
+  CheckCircle,
+  ChevronLeft,
+  ChevronRight,
+  Clock,
+  Loader2,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { getSessionRequests } from "@/actions/active-sessions";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+interface RequestItem {
+  id: number;
+  sequence: number;
+  model: string | null;
+  statusCode: number | null;
+  costUsd: string | null;
+  createdAt: Date | null;
+  inputTokens: number | null;
+  outputTokens: number | null;
+  errorMessage: string | null;
+}
+
+interface RequestListSidebarProps {
+  sessionId: string;
+  selectedSeq: number | null;
+  onSelect: (seq: number) => void;
+  collapsed?: boolean;
+  onCollapsedChange?: (collapsed: boolean) => void;
+}
+
+/**
+ * Request List Sidebar - Session 内请求列表侧边栏
+ * 显示 Session 中所有请求,支持分页和选择
+ */
+export function RequestListSidebar({
+  sessionId,
+  selectedSeq,
+  onSelect,
+  collapsed = false,
+  onCollapsedChange,
+}: RequestListSidebarProps) {
+  const t = useTranslations("dashboard.sessions");
+  const [requests, setRequests] = useState<RequestItem[]>([]);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [hasMore, setHasMore] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const pageSize = 20;
+
+  const fetchRequests = useCallback(
+    async (pageNum: number) => {
+      setIsLoading(true);
+      setError(null);
+
+      try {
+        const result = await getSessionRequests(sessionId, pageNum, pageSize);
+        if (result.ok) {
+          setRequests(result.data.requests);
+          setTotal(result.data.total);
+          setHasMore(result.data.hasMore);
+        } else {
+          setError(result.error || t("requestList.fetchFailed"));
+        }
+      } catch (err) {
+        setError(
+          err instanceof Error ? err.message : t("requestList.unknownError"),
+        );
+      } finally {
+        setIsLoading(false);
+      }
+    },
+    [sessionId, t],
+  );
+
+  useEffect(() => {
+    void fetchRequests(page);
+  }, [fetchRequests, page]);
+
+  // 格式化相对时间
+  const formatRelativeTime = (date: Date | null) => {
+    if (!date) return "-";
+    const now = new Date();
+    const diff = now.getTime() - new Date(date).getTime();
+    const minutes = Math.floor(diff / 60000);
+    const hours = Math.floor(minutes / 60);
+    const days = Math.floor(hours / 24);
+
+    if (days > 0) return `${days}d`;
+    if (hours > 0) return `${hours}h`;
+    if (minutes > 0) return `${minutes}m`;
+    return "<1m";
+  };
+
+  // 获取状态图标
+  const getStatusIcon = (statusCode: number | null) => {
+    if (!statusCode) {
+      return <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />;
+    }
+    if (statusCode >= 200 && statusCode < 300) {
+      return <CheckCircle className="h-3 w-3 text-green-600" />;
+    }
+    return <AlertCircle className="h-3 w-3 text-destructive" />;
+  };
+
+  // 折叠时只显示切换按钮
+  if (collapsed) {
+    return (
+      <div className="w-10 border-r bg-muted/30 flex flex-col items-center py-2">
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-8 w-8"
+          onClick={() => onCollapsedChange?.(false)}
+          aria-label={t("requestList.title")}
+        >
+          <ChevronRight className="h-4 w-4" />
+        </Button>
+        {total > 0 && (
+          <Badge variant="secondary" className="mt-2 text-xs px-1.5">
+            {total}
+          </Badge>
+        )}
+      </div>
+    );
+  }
+
+  return (
+    <div className="w-64 border-r bg-muted/30 flex flex-col">
+      {/* Header */}
+      <div className="p-3 border-b flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <h3 className="text-sm font-semibold">{t("requestList.title")}</h3>
+          {total > 0 && (
+            <Badge variant="secondary" className="text-xs">
+              {total}
+            </Badge>
+          )}
+        </div>
+        <Button
+          variant="ghost"
+          size="icon"
+          className="h-6 w-6"
+          onClick={() => onCollapsedChange?.(true)}
+        >
+          <ChevronLeft className="h-4 w-4" />
+        </Button>
+      </div>
+
+      {/* Request List */}
+      <div className="flex-1 overflow-y-auto">
+        <div className="p-2 space-y-1">
+          {isLoading && requests.length === 0 ? (
+            // Loading skeleton
+            Array.from({ length: 5 }).map((_, i) => (
+              <div key={i} className="p-2 rounded-md">
+                <Skeleton className="h-4 w-16 mb-1" />
+                <Skeleton className="h-3 w-24" />
+              </div>
+            ))
+          ) : error ? (
+            <div className="p-4 text-center text-sm text-destructive">
+              {error}
+            </div>
+          ) : requests.length === 0 ? (
+            <div className="p-4 text-center text-sm text-muted-foreground">
+              {t("requestList.noRequests")}
+            </div>
+          ) : (
+            requests.map((request) => (
+              <button
+                key={request.id}
+                type="button"
+                className={cn(
+                  "w-full p-2 rounded-md text-left transition-colors",
+                  "hover:bg-accent hover:text-accent-foreground",
+                  selectedSeq === request.sequence &&
+                    "bg-accent text-accent-foreground",
+                )}
+                onClick={() => onSelect(request.sequence)}
+              >
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-1.5">
+                    {getStatusIcon(request.statusCode)}
+                    <span className="text-sm font-medium">
+                      #{request.sequence}
+                    </span>
+                  </div>
+                  <div className="flex items-center gap-1 text-xs text-muted-foreground">
+                    <Clock className="h-3 w-3" />
+                    {formatRelativeTime(request.createdAt)}
+                  </div>
+                </div>
+                <div className="mt-1 flex items-center justify-between">
+                  <span className="text-xs text-muted-foreground font-mono truncate max-w-[120px]">
+                    {request.model || "-"}
+                  </span>
+                  {request.statusCode && (
+                    <Badge
+                      variant="outline"
+                      className={cn(
+                        "text-[10px] px-1 py-0",
+                        request.statusCode >= 200 && request.statusCode < 300
+                          ? "border-green-300 text-green-700 dark:border-green-700 dark:text-green-400"
+                          : "border-red-300 text-red-700 dark:border-red-700 dark:text-red-400",
+                      )}
+                    >
+                      {request.statusCode}
+                    </Badge>
+                  )}
+                </div>
+              </button>
+            ))
+          )}
+        </div>
+      </div>
+
+      {/* Pagination */}
+      {total > pageSize && (
+        <div className="p-2 border-t flex items-center justify-between">
+          <Button
+            variant="ghost"
+            size="sm"
+            className="h-7 text-xs"
+            disabled={page === 1 || isLoading}
+            onClick={() => setPage((p) => p - 1)}
+          >
+            {t("requestList.prev")}
+          </Button>
+          <span className="text-xs text-muted-foreground">
+            {page}/{Math.ceil(total / pageSize)}
+          </span>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="h-7 text-xs"
+            disabled={!hasMore || isLoading}
+            onClick={() => setPage((p) => p + 1)}
+          >
+            {t("requestList.next")}
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}

+ 354 - 301
src/app/[locale]/dashboard/sessions/[sessionId]/messages/page.tsx

@@ -2,9 +2,9 @@
 
 import { useQuery } from "@tanstack/react-query";
 import { ArrowLeft, Check, Copy, Download, Hash, Monitor, XCircle } from "lucide-react";
-import { useParams } from "next/navigation";
+import { useParams, useSearchParams } from "next/navigation";
 import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 import { toast } from "sonner";
 import { getSessionDetails, terminateActiveSession } from "@/actions/active-sessions";
 import { Section } from "@/components/section";
@@ -23,8 +23,11 @@ import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { useRouter } from "@/i18n/routing";
 import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
+import { RequestListSidebar } from "./_components/request-list-sidebar";
 
-async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }> {
+async function fetchSystemSettings(): Promise<{
+  currencyDisplay: CurrencyCode;
+}> {
   const response = await fetch("/api/system-settings");
   if (!response.ok) {
     throw new Error("Failed to fetch system settings");
@@ -34,27 +37,34 @@ async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }>
 
 /**
  * Session Messages 详情页面
- * 双栏布局:左侧完整内容 + 右侧信息卡片
+ * 三栏布局:左侧请求列表 + 中间完整内容 + 右侧信息卡片
  */
 export default function SessionMessagesPage() {
   const t = useTranslations("dashboard.sessions");
   const tDesc = useTranslations("dashboard.description");
   const params = useParams();
+  const searchParams = useSearchParams();
   const router = useRouter();
   const sessionId = params.sessionId as string;
 
+  // 从 URL 获取当前选中的请求序号
+  const seqParam = searchParams.get("seq");
+  const selectedSeq = seqParam ? parseInt(seqParam, 10) : null;
+
   const [messages, setMessages] = useState<unknown | null>(null);
   const [response, setResponse] = useState<string | null>(null);
   const [sessionStats, setSessionStats] =
     useState<
       Extract<Awaited<ReturnType<typeof getSessionDetails>>, { ok: true }>["data"]["sessionStats"]
     >(null);
+  const [currentSequence, setCurrentSequence] = useState<number | null>(null);
   const [isLoading, setIsLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [copiedMessages, setCopiedMessages] = useState(false);
   const [copiedResponse, setCopiedResponse] = useState(false);
   const [showTerminateDialog, setShowTerminateDialog] = useState(false);
   const [isTerminating, setIsTerminating] = useState(false);
+  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
 
   const { data: systemSettings } = useQuery({
     queryKey: ["system-settings"],
@@ -63,17 +73,29 @@ export default function SessionMessagesPage() {
 
   const currencyCode = systemSettings?.currencyDisplay || "USD";
 
+  // 处理请求选择(更新 URL)
+  const handleSelectRequest = useCallback(
+    (seq: number) => {
+      const url = new URL(window.location.href);
+      url.searchParams.set("seq", seq.toString());
+      router.replace(url.pathname + url.search);
+    },
+    [router]
+  );
+
   useEffect(() => {
     const fetchDetails = async () => {
       setIsLoading(true);
       setError(null);
 
       try {
-        const result = await getSessionDetails(sessionId);
+        // 传入 requestSequence 参数以获取特定请求的消息
+        const result = await getSessionDetails(sessionId, selectedSeq ?? undefined);
         if (result.ok) {
           setMessages(result.data.messages);
           setResponse(result.data.response);
           setSessionStats(result.data.sessionStats);
+          setCurrentSequence(result.data.currentSequence);
         } else {
           setError(result.error || t("status.fetchFailed"));
         }
@@ -85,7 +107,7 @@ export default function SessionMessagesPage() {
     };
 
     void fetchDetails();
-  }, [sessionId, t]);
+  }, [sessionId, selectedSeq, t]);
 
   const handleCopyMessages = async () => {
     if (!messages) return;
@@ -163,113 +185,50 @@ export default function SessionMessagesPage() {
     (sessionStats?.totalCacheReadTokens || 0);
 
   return (
-    <div className="space-y-6">
-      {/* 标题栏 */}
-      <div className="flex items-center justify-between">
-        <div className="flex items-center gap-4">
-          <Button variant="outline" size="sm" onClick={() => router.back()}>
-            <ArrowLeft className="h-4 w-4 mr-2" />
-            {t("actions.back")}
-          </Button>
-          <div>
-            <h1 className="text-2xl font-bold">{t("details.title")}</h1>
-            <p className="text-sm text-muted-foreground font-mono mt-1">{sessionId}</p>
-          </div>
-        </div>
-
-        {/* 操作按钮 */}
-        <div className="flex gap-2">
-          {messages !== null && (
-            <>
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={handleCopyMessages}
-                disabled={copiedMessages}
-              >
-                {copiedMessages ? (
-                  <>
-                    <Check className="h-4 w-4 mr-2" />
-                    {t("actions.copied")}
-                  </>
-                ) : (
-                  <>
-                    <Copy className="h-4 w-4 mr-2" />
-                    {t("actions.copyMessages")}
-                  </>
-                )}
+    <div className="flex h-full">
+      {/* 左侧:请求列表侧边栏 */}
+      <RequestListSidebar
+        sessionId={sessionId}
+        selectedSeq={selectedSeq ?? currentSequence}
+        onSelect={handleSelectRequest}
+        collapsed={sidebarCollapsed}
+        onCollapsedChange={setSidebarCollapsed}
+      />
+
+      {/* 主内容区域 */}
+      <div className="flex-1 overflow-auto">
+        <div className="space-y-6 p-6">
+          {/* 标题栏 */}
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-4">
+              <Button variant="outline" size="sm" onClick={() => router.back()}>
+                <ArrowLeft className="h-4 w-4 mr-2" />
+                {t("actions.back")}
               </Button>
-              <Button variant="outline" size="sm" onClick={handleDownload}>
-                <Download className="h-4 w-4 mr-2" />
-                {t("actions.downloadMessages")}
-              </Button>
-            </>
-          )}
-          {/* 终止 Session 按钮 */}
-          {sessionStats && (
-            <Button
-              variant="destructive"
-              size="sm"
-              onClick={() => setShowTerminateDialog(true)}
-              disabled={isTerminating}
-            >
-              <XCircle className="h-4 w-4 mr-2" />
-              {t("actions.terminate")}
-            </Button>
-          )}
-        </div>
-      </div>
-
-      {/* 内容区域 */}
-      {isLoading ? (
-        <div className="text-center py-16 text-muted-foreground">{t("status.loading")}</div>
-      ) : error ? (
-        <div className="text-center py-16">
-          <div className="text-destructive text-lg mb-2">{error}</div>
-        </div>
-      ) : (
-        <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
-          {/* 左侧:完整内容(占 2 列)*/}
-          <div className="lg:col-span-2 space-y-6">
-            {/* User-Agent 信息 */}
-            {sessionStats?.userAgent && (
-              <Section title={t("details.clientInfo")} description={tDesc("clientInfo")}>
-                <div className="rounded-md border bg-muted/50 p-4">
-                  <div className="flex items-start gap-3">
-                    <Monitor className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
-                    <code className="text-sm font-mono break-all">{sessionStats.userAgent}</code>
-                  </div>
-                </div>
-              </Section>
-            )}
-
-            {/* Messages 数据 */}
-            {messages !== null && (
-              <Section
-                title={t("details.requestMessages")}
-                description={t("details.requestMessagesDescription")}
-              >
-                <div className="rounded-md border bg-muted/50 p-6">
-                  <pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words font-mono">
-                    {JSON.stringify(messages, null, 2)}
-                  </pre>
+              <div>
+                <div className="flex items-center gap-2">
+                  <h1 className="text-2xl font-bold">{t("details.title")}</h1>
+                  {(selectedSeq ?? currentSequence) && (
+                    <Badge variant="outline" className="text-sm">
+                      #{selectedSeq ?? currentSequence}
+                    </Badge>
+                  )}
                 </div>
-              </Section>
-            )}
-
-            {/* Response Body */}
-            {response !== null && (
-              <Section
-                title={t("details.responseBody")}
-                description={t("details.responseBodyDescription")}
-                actions={
+                <p className="text-sm text-muted-foreground font-mono mt-1">{sessionId}</p>
+              </div>
+            </div>
+
+            {/* 操作按钮 */}
+            <div className="flex gap-2">
+              {messages !== null && (
+                <>
                   <Button
-                    variant="ghost"
+                    variant="outline"
                     size="sm"
-                    onClick={handleCopyResponse}
-                    disabled={copiedResponse}
+                    onClick={handleCopyMessages}
+                    disabled={copiedMessages}
                   >
-                    {copiedResponse ? (
+                    {copiedMessages ? (
                       <>
                         <Check className="h-4 w-4 mr-2" />
                         {t("actions.copied")}
@@ -277,224 +236,318 @@ export default function SessionMessagesPage() {
                     ) : (
                       <>
                         <Copy className="h-4 w-4 mr-2" />
-                        {t("actions.copyResponse")}
+                        {t("actions.copyMessages")}
                       </>
                     )}
                   </Button>
-                }
-              >
-                <div className="rounded-md border bg-muted/50 p-6 max-h-[600px] overflow-auto">
-                  <pre className="text-xs whitespace-pre-wrap break-words font-mono">
-                    {formatResponse(response)}
-                  </pre>
-                </div>
-              </Section>
-            )}
-
-            {/* 无数据提示 */}
-            {!sessionStats?.userAgent && !messages && !response && (
-              <div className="text-center py-16">
-                <div className="text-muted-foreground text-lg mb-2">
-                  {t("details.noDetailedData")}
-                </div>
-                <p className="text-sm text-muted-foreground">{t("details.storageTip")}</p>
-              </div>
-            )}
+                  <Button variant="outline" size="sm" onClick={handleDownload}>
+                    <Download className="h-4 w-4 mr-2" />
+                    {t("actions.downloadMessages")}
+                  </Button>
+                </>
+              )}
+              {/* 终止 Session 按钮 */}
+              {sessionStats && (
+                <Button
+                  variant="destructive"
+                  size="sm"
+                  onClick={() => setShowTerminateDialog(true)}
+                  disabled={isTerminating}
+                >
+                  <XCircle className="h-4 w-4 mr-2" />
+                  {t("actions.terminate")}
+                </Button>
+              )}
+            </div>
           </div>
 
-          {/* 右侧:信息卡片(占 1 列)*/}
-          {sessionStats && (
-            <div className="space-y-4">
-              {/* Session 概览卡片 */}
-              <Card>
-                <CardHeader>
-                  <CardTitle className="text-base">{t("details.overview")}</CardTitle>
-                  <CardDescription>{t("details.overviewDescription")}</CardDescription>
-                </CardHeader>
-                <CardContent className="space-y-3">
-                  {/* 请求数量 */}
-                  <div className="flex items-center justify-between">
-                    <span className="text-sm text-muted-foreground">
-                      {t("details.totalRequests")}
-                    </span>
-                    <Badge variant="secondary" className="font-mono font-semibold">
-                      <Hash className="h-3 w-3 mr-1" />
-                      {sessionStats.requestCount}
-                    </Badge>
+          {/* 内容区域 */}
+          {isLoading ? (
+            <div className="text-center py-16 text-muted-foreground">{t("status.loading")}</div>
+          ) : error ? (
+            <div className="text-center py-16">
+              <div className="text-destructive text-lg mb-2">{error}</div>
+            </div>
+          ) : (
+            <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+              {/* 左侧:完整内容(占 2 列)*/}
+              <div className="lg:col-span-2 space-y-6">
+                {/* User-Agent 信息 */}
+                {sessionStats?.userAgent && (
+                  <Section title={t("details.clientInfo")} description={tDesc("clientInfo")}>
+                    <div className="rounded-md border bg-muted/50 p-4">
+                      <div className="flex items-start gap-3">
+                        <Monitor className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
+                        <code className="text-sm font-mono break-all">
+                          {sessionStats.userAgent}
+                        </code>
+                      </div>
+                    </div>
+                  </Section>
+                )}
+
+                {/* Messages 数据 */}
+                {messages !== null && (
+                  <Section
+                    title={t("details.requestMessages")}
+                    description={t("details.requestMessagesDescription")}
+                  >
+                    <div className="rounded-md border bg-muted/50 p-6">
+                      <pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words font-mono">
+                        {JSON.stringify(messages, null, 2)}
+                      </pre>
+                    </div>
+                  </Section>
+                )}
+
+                {/* Response Body */}
+                {response !== null && (
+                  <Section
+                    title={t("details.responseBody")}
+                    description={t("details.responseBodyDescription")}
+                    actions={
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        onClick={handleCopyResponse}
+                        disabled={copiedResponse}
+                      >
+                        {copiedResponse ? (
+                          <>
+                            <Check className="h-4 w-4 mr-2" />
+                            {t("actions.copied")}
+                          </>
+                        ) : (
+                          <>
+                            <Copy className="h-4 w-4 mr-2" />
+                            {t("actions.copyResponse")}
+                          </>
+                        )}
+                      </Button>
+                    }
+                  >
+                    <div className="rounded-md border bg-muted/50 p-6 max-h-[600px] overflow-auto">
+                      <pre className="text-xs whitespace-pre-wrap break-words font-mono">
+                        {formatResponse(response)}
+                      </pre>
+                    </div>
+                  </Section>
+                )}
+
+                {/* 无数据提示 */}
+                {!sessionStats?.userAgent && !messages && !response && (
+                  <div className="text-center py-16">
+                    <div className="text-muted-foreground text-lg mb-2">
+                      {t("details.noDetailedData")}
+                    </div>
+                    <p className="text-sm text-muted-foreground">{t("details.storageTip")}</p>
                   </div>
+                )}
+              </div>
+
+              {/* 右侧:信息卡片(占 1 列)*/}
+              {sessionStats && (
+                <div className="space-y-4">
+                  {/* Session 概览卡片 */}
+                  <Card>
+                    <CardHeader>
+                      <CardTitle className="text-base">{t("details.overview")}</CardTitle>
+                      <CardDescription>{t("details.overviewDescription")}</CardDescription>
+                    </CardHeader>
+                    <CardContent className="space-y-3">
+                      {/* 请求数量 */}
+                      <div className="flex items-center justify-between">
+                        <span className="text-sm text-muted-foreground">
+                          {t("details.totalRequests")}
+                        </span>
+                        <Badge variant="secondary" className="font-mono font-semibold">
+                          <Hash className="h-3 w-3 mr-1" />
+                          {sessionStats.requestCount}
+                        </Badge>
+                      </div>
 
-                  {/* 时间跨度 */}
-                  {sessionStats.firstRequestAt && sessionStats.lastRequestAt && (
-                    <>
-                      <div className="border-t my-3" />
-                      <div className="flex flex-col gap-2">
+                      {/* 时间跨度 */}
+                      {sessionStats.firstRequestAt && sessionStats.lastRequestAt && (
+                        <>
+                          <div className="border-t my-3" />
+                          <div className="flex flex-col gap-2">
+                            <div className="flex items-center justify-between">
+                              <span className="text-sm text-muted-foreground">
+                                {t("details.firstRequest")}
+                              </span>
+                              <code className="text-xs font-mono">
+                                {new Date(sessionStats.firstRequestAt).toLocaleString("zh-CN")}
+                              </code>
+                            </div>
+                            <div className="flex items-center justify-between">
+                              <span className="text-sm text-muted-foreground">
+                                {t("details.lastRequest")}
+                              </span>
+                              <code className="text-xs font-mono">
+                                {new Date(sessionStats.lastRequestAt).toLocaleString("zh-CN")}
+                              </code>
+                            </div>
+                          </div>
+                        </>
+                      )}
+
+                      {/* 总耗时 */}
+                      {sessionStats.totalDurationMs > 0 && (
+                        <>
+                          <div className="border-t my-3" />
+                          <div className="flex items-center justify-between">
+                            <span className="text-sm text-muted-foreground">
+                              {t("details.totalDuration")}
+                            </span>
+                            <code className="text-sm font-mono font-semibold">
+                              {sessionStats.totalDurationMs < 1000
+                                ? `${sessionStats.totalDurationMs}ms`
+                                : `${(sessionStats.totalDurationMs / 1000).toFixed(2)}s`}
+                            </code>
+                          </div>
+                        </>
+                      )}
+                    </CardContent>
+                  </Card>
+
+                  {/* 供应商和模型卡片 */}
+                  <Card>
+                    <CardHeader>
+                      <CardTitle className="text-base">{t("details.providersAndModels")}</CardTitle>
+                      <CardDescription>
+                        {t("details.providersAndModelsDescription")}
+                      </CardDescription>
+                    </CardHeader>
+                    <CardContent className="space-y-3">
+                      {/* 供应商列表 */}
+                      {sessionStats.providers.length > 0 && (
+                        <div className="flex flex-col gap-2">
+                          <span className="text-sm text-muted-foreground">
+                            {t("details.providers")}
+                          </span>
+                          <div className="flex flex-wrap gap-2">
+                            {sessionStats.providers.map(
+                              (provider: { id: number; name: string }) => (
+                                <Badge key={provider.id} variant="outline" className="text-xs">
+                                  {provider.name}
+                                </Badge>
+                              )
+                            )}
+                          </div>
+                        </div>
+                      )}
+
+                      {/* 模型列表 */}
+                      {sessionStats.models.length > 0 && (
+                        <>
+                          <div className="border-t my-3" />
+                          <div className="flex flex-col gap-2">
+                            <span className="text-sm text-muted-foreground">
+                              {t("details.models")}
+                            </span>
+                            <div className="flex flex-wrap gap-2">
+                              {sessionStats.models.map((model: string, idx: number) => (
+                                <Badge key={idx} variant="secondary" className="text-xs font-mono">
+                                  {model}
+                                </Badge>
+                              ))}
+                            </div>
+                          </div>
+                        </>
+                      )}
+                    </CardContent>
+                  </Card>
+
+                  {/* Token 使用卡片 */}
+                  <Card>
+                    <CardHeader>
+                      <CardTitle className="text-base">{t("details.tokenUsage")}</CardTitle>
+                      <CardDescription>{t("details.tokenUsageDescription")}</CardDescription>
+                    </CardHeader>
+                    <CardContent className="space-y-3">
+                      {sessionStats.totalInputTokens > 0 && (
                         <div className="flex items-center justify-between">
                           <span className="text-sm text-muted-foreground">
-                            {t("details.firstRequest")}
+                            {t("details.totalInput")}
                           </span>
-                          <code className="text-xs font-mono">
-                            {new Date(sessionStats.firstRequestAt).toLocaleString("zh-CN")}
+                          <code className="text-sm font-mono">
+                            {sessionStats.totalInputTokens.toLocaleString()}
                           </code>
                         </div>
+                      )}
+
+                      {sessionStats.totalOutputTokens > 0 && (
                         <div className="flex items-center justify-between">
                           <span className="text-sm text-muted-foreground">
-                            {t("details.lastRequest")}
+                            {t("details.totalOutput")}
                           </span>
-                          <code className="text-xs font-mono">
-                            {new Date(sessionStats.lastRequestAt).toLocaleString("zh-CN")}
+                          <code className="text-sm font-mono">
+                            {sessionStats.totalOutputTokens.toLocaleString()}
                           </code>
                         </div>
-                      </div>
-                    </>
-                  )}
-
-                  {/* 总耗时 */}
-                  {sessionStats.totalDurationMs > 0 && (
-                    <>
-                      <div className="border-t my-3" />
-                      <div className="flex items-center justify-between">
-                        <span className="text-sm text-muted-foreground">
-                          {t("details.totalDuration")}
-                        </span>
-                        <code className="text-sm font-mono font-semibold">
-                          {sessionStats.totalDurationMs < 1000
-                            ? `${sessionStats.totalDurationMs}ms`
-                            : `${(sessionStats.totalDurationMs / 1000).toFixed(2)}s`}
-                        </code>
-                      </div>
-                    </>
-                  )}
-                </CardContent>
-              </Card>
-
-              {/* 供应商和模型卡片 */}
-              <Card>
-                <CardHeader>
-                  <CardTitle className="text-base">{t("details.providersAndModels")}</CardTitle>
-                  <CardDescription>{t("details.providersAndModelsDescription")}</CardDescription>
-                </CardHeader>
-                <CardContent className="space-y-3">
-                  {/* 供应商列表 */}
-                  {sessionStats.providers.length > 0 && (
-                    <div className="flex flex-col gap-2">
-                      <span className="text-sm text-muted-foreground">
-                        {t("details.providers")}
-                      </span>
-                      <div className="flex flex-wrap gap-2">
-                        {sessionStats.providers.map((provider: { id: number; name: string }) => (
-                          <Badge key={provider.id} variant="outline" className="text-xs">
-                            {provider.name}
-                          </Badge>
-                        ))}
-                      </div>
-                    </div>
-                  )}
+                      )}
 
-                  {/* 模型列表 */}
-                  {sessionStats.models.length > 0 && (
-                    <>
-                      <div className="border-t my-3" />
-                      <div className="flex flex-col gap-2">
-                        <span className="text-sm text-muted-foreground">{t("details.models")}</span>
-                        <div className="flex flex-wrap gap-2">
-                          {sessionStats.models.map((model: string, idx: number) => (
-                            <Badge key={idx} variant="secondary" className="text-xs font-mono">
-                              {model}
-                            </Badge>
-                          ))}
+                      {sessionStats.totalCacheCreationTokens > 0 && (
+                        <div className="flex items-center justify-between">
+                          <span className="text-sm text-muted-foreground">
+                            {t("details.cacheCreation")}
+                          </span>
+                          <code className="text-sm font-mono">
+                            {sessionStats.totalCacheCreationTokens.toLocaleString()}
+                          </code>
                         </div>
-                      </div>
-                    </>
-                  )}
-                </CardContent>
-              </Card>
-
-              {/* Token 使用卡片 */}
-              <Card>
-                <CardHeader>
-                  <CardTitle className="text-base">{t("details.tokenUsage")}</CardTitle>
-                  <CardDescription>{t("details.tokenUsageDescription")}</CardDescription>
-                </CardHeader>
-                <CardContent className="space-y-3">
-                  {sessionStats.totalInputTokens > 0 && (
-                    <div className="flex items-center justify-between">
-                      <span className="text-sm text-muted-foreground">
-                        {t("details.totalInput")}
-                      </span>
-                      <code className="text-sm font-mono">
-                        {sessionStats.totalInputTokens.toLocaleString()}
-                      </code>
-                    </div>
-                  )}
+                      )}
 
-                  {sessionStats.totalOutputTokens > 0 && (
-                    <div className="flex items-center justify-between">
-                      <span className="text-sm text-muted-foreground">
-                        {t("details.totalOutput")}
-                      </span>
-                      <code className="text-sm font-mono">
-                        {sessionStats.totalOutputTokens.toLocaleString()}
-                      </code>
-                    </div>
-                  )}
-
-                  {sessionStats.totalCacheCreationTokens > 0 && (
-                    <div className="flex items-center justify-between">
-                      <span className="text-sm text-muted-foreground">
-                        {t("details.cacheCreation")}
-                      </span>
-                      <code className="text-sm font-mono">
-                        {sessionStats.totalCacheCreationTokens.toLocaleString()}
-                      </code>
-                    </div>
-                  )}
-
-                  {sessionStats.totalCacheReadTokens > 0 && (
-                    <div className="flex items-center justify-between">
-                      <span className="text-sm text-muted-foreground">
-                        {t("details.cacheRead")}
-                      </span>
-                      <code className="text-sm font-mono">
-                        {sessionStats.totalCacheReadTokens.toLocaleString()}
-                      </code>
-                    </div>
-                  )}
-
-                  {totalTokens > 0 && (
-                    <>
-                      <div className="border-t my-3" />
-                      <div className="flex items-center justify-between">
-                        <span className="text-sm font-semibold">{t("details.total")}</span>
-                        <code className="text-sm font-mono font-semibold">
-                          {totalTokens.toLocaleString()}
-                        </code>
-                      </div>
-                    </>
+                      {sessionStats.totalCacheReadTokens > 0 && (
+                        <div className="flex items-center justify-between">
+                          <span className="text-sm text-muted-foreground">
+                            {t("details.cacheRead")}
+                          </span>
+                          <code className="text-sm font-mono">
+                            {sessionStats.totalCacheReadTokens.toLocaleString()}
+                          </code>
+                        </div>
+                      )}
+
+                      {totalTokens > 0 && (
+                        <>
+                          <div className="border-t my-3" />
+                          <div className="flex items-center justify-between">
+                            <span className="text-sm font-semibold">{t("details.total")}</span>
+                            <code className="text-sm font-mono font-semibold">
+                              {totalTokens.toLocaleString()}
+                            </code>
+                          </div>
+                        </>
+                      )}
+                    </CardContent>
+                  </Card>
+
+                  {/* 成本信息卡片 */}
+                  {sessionStats.totalCostUsd && parseFloat(sessionStats.totalCostUsd) > 0 && (
+                    <Card>
+                      <CardHeader>
+                        <CardTitle className="text-base">{t("details.costInfo")}</CardTitle>
+                        <CardDescription>{t("details.costInfoDescription")}</CardDescription>
+                      </CardHeader>
+                      <CardContent className="space-y-3">
+                        <div className="flex items-center justify-between">
+                          <span className="text-sm text-muted-foreground">
+                            {t("details.totalFee")}
+                          </span>
+                          <code className="text-lg font-mono font-semibold text-green-600">
+                            {formatCurrency(sessionStats.totalCostUsd, currencyCode, 6)}
+                          </code>
+                        </div>
+                      </CardContent>
+                    </Card>
                   )}
-                </CardContent>
-              </Card>
-
-              {/* 成本信息卡片 */}
-              {sessionStats.totalCostUsd && parseFloat(sessionStats.totalCostUsd) > 0 && (
-                <Card>
-                  <CardHeader>
-                    <CardTitle className="text-base">{t("details.costInfo")}</CardTitle>
-                    <CardDescription>{t("details.costInfoDescription")}</CardDescription>
-                  </CardHeader>
-                  <CardContent className="space-y-3">
-                    <div className="flex items-center justify-between">
-                      <span className="text-sm text-muted-foreground">{t("details.totalFee")}</span>
-                      <code className="text-lg font-mono font-semibold text-green-600">
-                        {formatCurrency(sessionStats.totalCostUsd, currencyCode, 6)}
-                      </code>
-                    </div>
-                  </CardContent>
-                </Card>
+                </div>
               )}
             </div>
           )}
         </div>
-      )}
+      </div>
 
       {/* 终止 Session 确认对话框 */}
       <AlertDialog open={showTerminateDialog} onOpenChange={setShowTerminateDialog}>

+ 1 - 0
src/app/v1/_lib/proxy/message-service.ts

@@ -37,6 +37,7 @@ export class ProxyMessageService {
       key: authState.apiKey,
       model: session.request.model ?? undefined,
       session_id: session.sessionId ?? undefined, // 传入 session_id
+      request_sequence: session.getRequestSequence(), // 传入请求序号(Session 内)
       cost_multiplier: provider.costMultiplier, // 传入 cost_multiplier
       user_agent: session.userAgent ?? undefined, // 传入 user_agent
       original_model: session.getOriginalModel() ?? undefined, // 传入原始模型(用户请求的模型)

+ 7 - 3
src/app/v1/_lib/proxy/session-guard.ts

@@ -34,6 +34,10 @@ export class ProxySessionGuard {
       // 4. 设置到 session 对象
       session.setSessionId(sessionId);
 
+      // 4.1 获取并设置请求序号(Session 内唯一标识每个请求)
+      const requestSequence = await SessionManager.getNextRequestSequence(sessionId);
+      session.setRequestSequence(requestSequence);
+
       // 5. 追踪 session(添加到活跃集合)
       void SessionTracker.trackSession(sessionId, keyId).catch((err) => {
         logger.error("[ProxySessionGuard] Failed to track session:", err);
@@ -52,10 +56,10 @@ export class ProxySessionGuard {
               apiType: session.originalFormat === "openai" ? "codex" : "chat",
             });
 
-            // 可选:存储 messages(受环境变量控制)
+            // 可选:存储 messages(受环境变量控制,按请求序号独立存储
             const messages = session.getMessages();
             if (messages) {
-              await SessionManager.storeSessionMessages(sessionId, messages);
+              await SessionManager.storeSessionMessages(sessionId, messages, requestSequence);
             }
           }
         } catch (error) {
@@ -64,7 +68,7 @@ export class ProxySessionGuard {
       })();
 
       logger.debug(
-        `[ProxySessionGuard] Session assigned: ${sessionId} (key=${keyId}, messagesLength=${session.getMessagesLength()}, clientProvided=${!!clientSessionId})`
+        `[ProxySessionGuard] Session assigned: ${sessionId}:${requestSequence} (key=${keyId}, messagesLength=${session.getMessagesLength()}, clientProvided=${!!clientSessionId})`
       );
     } catch (error) {
       logger.error("[ProxySessionGuard] Failed to assign session:", error);

+ 17 - 0
src/app/v1/_lib/proxy/session.ts

@@ -54,6 +54,9 @@ export class ProxySession {
   // Session ID(用于会话粘性和并发限流)
   sessionId: string | null;
 
+  // Request Sequence(Session 内请求序号)
+  requestSequence: number = 1;
+
   // 请求格式追踪:记录原始请求格式和供应商类型
   originalFormat: ClientFormat = "claude";
   providerType: ProviderType | null = null;
@@ -195,6 +198,20 @@ export class ProxySession {
     this.sessionId = sessionId;
   }
 
+  /**
+   * 设置请求序号(Session 内)
+   */
+  setRequestSequence(sequence: number): void {
+    this.requestSequence = sequence;
+  }
+
+  /**
+   * 获取请求序号(Session 内)
+   */
+  getRequestSequence(): number {
+    return this.requestSequence;
+  }
+
   /**
    * 生成基于请求指纹的确定性 Session ID
    *

+ 5 - 0
src/drizzle/schema.ts

@@ -243,6 +243,9 @@ export const messageRequest = pgTable('message_request', {
   // Session ID(用于会话粘性和日志追踪)
   sessionId: varchar('session_id', { length: 64 }),
 
+  // Request Sequence(Session 内请求序号,用于区分同一 Session 的不同请求)
+  requestSequence: integer('request_sequence').default(1),
+
   // 上游决策链(记录尝试的供应商列表)
   providerChain: jsonb('provider_chain').$type<Array<{ id: number; name: string }>>(),
 
@@ -290,6 +293,8 @@ export const messageRequest = pgTable('message_request', {
   messageRequestUserQueryIdx: index('idx_message_request_user_query').on(table.userId, table.createdAt).where(sql`${table.deletedAt} IS NULL`),
   // Session 查询索引(按 session 聚合查看对话)
   messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`),
+  // Session + Sequence 复合索引(用于 Session 内请求列表查询)
+  messageRequestSessionSeqIdx: index('idx_message_request_session_seq').on(table.sessionId, table.requestSequence).where(sql`${table.deletedAt} IS NULL`),
   // Endpoint 过滤查询索引(仅针对未删除数据)
   messageRequestEndpointIdx: index('idx_message_request_endpoint').on(table.endpoint).where(sql`${table.deletedAt} IS NULL`),
   // 基础索引

+ 120 - 13
src/lib/session-manager.ts

@@ -84,6 +84,58 @@ export class SessionManager {
     return `sess_${timestamp}_${random}`;
   }
 
+  /**
+   * 获取 Session 内下一个请求序号(原子操作)
+   *
+   * 使用 Redis INCR 保证并发安全,序号从 1 开始递增
+   * 每个请求在同一 Session 内获得唯一序号,用于独立存储 messages
+   *
+   * @param sessionId - Session ID
+   * @returns 请求序号(从 1 开始),Redis 不可用时返回 1
+   */
+  static async getNextRequestSequence(sessionId: string): Promise<number> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") {
+      logger.warn("SessionManager: Redis not ready, returning default sequence 1");
+      return 1;
+    }
+
+    try {
+      const key = `session:${sessionId}:seq`;
+      const sequence = await redis.incr(key);
+
+      // 首次创建时设置过期时间
+      if (sequence === 1) {
+        await redis.expire(key, SessionManager.SESSION_TTL);
+      }
+
+      logger.trace("SessionManager: Got next request sequence", { sessionId, sequence });
+      return sequence;
+    } catch (error) {
+      logger.error("SessionManager: Failed to get request sequence", { error, sessionId });
+      return 1;
+    }
+  }
+
+  /**
+   * 获取 Session 当前的请求计数
+   *
+   * @param sessionId - Session ID
+   * @returns 当前请求数量,不存在返回 0
+   */
+  static async getSessionRequestCount(sessionId: string): Promise<number> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== "ready") return 0;
+
+    try {
+      const count = await redis.get(`session:${sessionId}:seq`);
+      return count ? parseInt(count, 10) : 0;
+    } catch (error) {
+      logger.error("SessionManager: Failed to get request count", { error, sessionId });
+      return 0;
+    }
+  }
+
   /**
    * 计算 messages 内容哈希(用于 session 匹配)
    *
@@ -754,8 +806,16 @@ export class SessionManager {
 
   /**
    * 存储 session 请求 messages(可选,受环境变量控制)
+   *
+   * @param sessionId - Session ID
+   * @param messages - 消息内容
+   * @param requestSequence - 可选,请求序号。提供时使用新的 key 格式存储独立消息
    */
-  static async storeSessionMessages(sessionId: string, messages: unknown): Promise<void> {
+  static async storeSessionMessages(
+    sessionId: string,
+    messages: unknown,
+    requestSequence?: number
+  ): Promise<void> {
     if (!SessionManager.STORE_MESSAGES) {
       logger.trace("SessionManager: STORE_SESSION_MESSAGES is disabled, skipping");
       return;
@@ -766,8 +826,17 @@ export class SessionManager {
 
     try {
       const messagesJson = JSON.stringify(messages);
-      await redis.setex(`session:${sessionId}:messages`, SessionManager.SESSION_TTL, messagesJson);
-      logger.trace("SessionManager: Stored session messages", { sessionId });
+      // 新格式:session:{sessionId}:req:{sequence}:messages(独立存储每个请求)
+      // 旧格式:session:{sessionId}:messages(向后兼容)
+      const key = requestSequence
+        ? `session:${sessionId}:req:${requestSequence}:messages`
+        : `session:${sessionId}:messages`;
+      await redis.setex(key, SessionManager.SESSION_TTL, messagesJson);
+      logger.trace("SessionManager: Stored session messages", {
+        sessionId,
+        requestSequence,
+        key,
+      });
     } catch (error) {
       logger.error("SessionManager: Failed to store session messages", { error });
     }
@@ -1045,8 +1114,15 @@ export class SessionManager {
 
   /**
    * 获取 session 的 messages 内容
+   *
+   * @param sessionId - Session ID
+   * @param requestSequence - 可选,请求序号。提供时读取特定请求的消息
+   * @returns 消息内容(解析后的 JSON 对象)
    */
-  static async getSessionMessages(sessionId: string): Promise<unknown | null> {
+  static async getSessionMessages(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<unknown | null> {
     if (!SessionManager.STORE_MESSAGES) {
       logger.warn("SessionManager: STORE_SESSION_MESSAGES is disabled");
       return null;
@@ -1056,7 +1132,18 @@ export class SessionManager {
     if (!redis || redis.status !== "ready") return null;
 
     try {
-      const messagesJson = await redis.get(`session:${sessionId}:messages`);
+      // 优先尝试新格式
+      if (requestSequence) {
+        const newKey = `session:${sessionId}:req:${requestSequence}:messages`;
+        const messagesJson = await redis.get(newKey);
+        if (messagesJson) {
+          return JSON.parse(messagesJson);
+        }
+      }
+
+      // 向后兼容:尝试旧格式
+      const legacyKey = `session:${sessionId}:messages`;
+      const messagesJson = await redis.get(legacyKey);
       if (!messagesJson) {
         return null;
       }
@@ -1072,20 +1159,27 @@ export class SessionManager {
    *
    * @param sessionId - Session ID
    * @param response - 响应体内容(字符串或对象)
+   * @param requestSequence - 可选,请求序号。提供时使用新的 key 格式存储独立响应
    */
-  static async storeSessionResponse(sessionId: string, response: string | object): Promise<void> {
+  static async storeSessionResponse(
+    sessionId: string,
+    response: string | object,
+    requestSequence?: number
+  ): Promise<void> {
     const redis = getRedisClient();
     if (!redis || redis.status !== "ready") return;
 
     try {
       const responseString = typeof response === "string" ? response : JSON.stringify(response);
-      await redis.setex(
-        `session:${sessionId}:response`,
-        SessionManager.SESSION_TTL,
-        responseString
-      );
+      // 新格式:session:{sessionId}:req:{sequence}:response(独立存储每个请求)
+      // 旧格式:session:{sessionId}:response(向后兼容)
+      const key = requestSequence
+        ? `session:${sessionId}:req:${requestSequence}:response`
+        : `session:${sessionId}:response`;
+      await redis.setex(key, SessionManager.SESSION_TTL, responseString);
       logger.trace("SessionManager: Stored session response", {
         sessionId,
+        requestSequence,
         size: responseString.length,
       });
     } catch (error) {
@@ -1097,14 +1191,27 @@ export class SessionManager {
    * 获取 session 响应体
    *
    * @param sessionId - Session ID
+   * @param requestSequence - 可选,请求序号。提供时读取特定请求的响应
    * @returns 响应体内容(字符串)
    */
-  static async getSessionResponse(sessionId: string): Promise<string | null> {
+  static async getSessionResponse(
+    sessionId: string,
+    requestSequence?: number
+  ): Promise<string | null> {
     const redis = getRedisClient();
     if (!redis || redis.status !== "ready") return null;
 
     try {
-      const response = await redis.get(`session:${sessionId}:response`);
+      // 优先尝试新格式
+      if (requestSequence) {
+        const newKey = `session:${sessionId}:req:${requestSequence}:response`;
+        const response = await redis.get(newKey);
+        if (response) return response;
+      }
+
+      // 向后兼容:尝试旧格式
+      const legacyKey = `session:${sessionId}:response`;
+      const response = await redis.get(legacyKey);
       return response;
     } catch (error) {
       logger.error("SessionManager: Failed to get session response", { error });

+ 1 - 0
src/repository/_shared/transformers.ts

@@ -99,6 +99,7 @@ export function toMessageRequest(dbMessage: any): MessageRequest {
   return {
     ...dbMessage,
     costMultiplier: dbMessage?.costMultiplier ? parseFloat(dbMessage.costMultiplier) : undefined,
+    requestSequence: dbMessage?.requestSequence ?? undefined,
     createdAt: dbMessage?.createdAt ? new Date(dbMessage.createdAt) : new Date(),
     updatedAt: dbMessage?.updatedAt ? new Date(dbMessage.updatedAt) : new Date(),
     costUsd: (() => {

+ 71 - 0
src/repository/message.ts

@@ -24,6 +24,7 @@ export async function createMessageRequest(
     costUsd: formattedCost ?? undefined,
     costMultiplier: data.cost_multiplier?.toString() ?? undefined, // 供应商倍率(转为字符串)
     sessionId: data.session_id, // Session ID
+    requestSequence: data.request_sequence, // Request Sequence(Session 内请求序号)
     userAgent: data.user_agent, // User-Agent
     endpoint: data.endpoint, // 请求端点(可为空)
     messagesCount: data.messages_count, // Messages 数量
@@ -45,6 +46,7 @@ export async function createMessageRequest(
     costUsd: messageRequest.costUsd,
     costMultiplier: messageRequest.costMultiplier, // 新增
     sessionId: messageRequest.sessionId, // 新增
+    requestSequence: messageRequest.requestSequence, // Request Sequence
     userAgent: messageRequest.userAgent, // 新增
     endpoint: messageRequest.endpoint, // 新增:返回端点
     messagesCount: messageRequest.messagesCount, // 新增
@@ -585,3 +587,72 @@ export async function findUsageLogs(params: {
 
   return { logs, total };
 }
+
+/**
+ * 查询指定 Session 的所有请求记录(用于 Session 详情页的请求列表)
+ *
+ * @param sessionId - Session ID
+ * @param options - 分页参数
+ * @returns 请求列表和总数
+ */
+export async function findRequestsBySessionId(
+  sessionId: string,
+  options?: { limit?: number; offset?: number }
+): Promise<{
+  requests: Array<{
+    id: number;
+    sequence: number;
+    model: string | null;
+    statusCode: number | null;
+    costUsd: string | null;
+    createdAt: Date | null;
+    inputTokens: number | null;
+    outputTokens: number | null;
+    errorMessage: string | null;
+  }>;
+  total: number;
+}> {
+  const { limit = 20, offset = 0 } = options || {};
+
+  // 查询总数
+  const [countResult] = await db
+    .select({ count: sql<number>`count(*)::int` })
+    .from(messageRequest)
+    .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt)));
+
+  const total = countResult?.count ?? 0;
+
+  // 查询分页数据,按 requestSequence 排序
+  const results = await db
+    .select({
+      id: messageRequest.id,
+      sequence: messageRequest.requestSequence,
+      model: messageRequest.model,
+      statusCode: messageRequest.statusCode,
+      costUsd: messageRequest.costUsd,
+      createdAt: messageRequest.createdAt,
+      inputTokens: messageRequest.inputTokens,
+      outputTokens: messageRequest.outputTokens,
+      errorMessage: messageRequest.errorMessage,
+    })
+    .from(messageRequest)
+    .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt)))
+    .orderBy(messageRequest.requestSequence)
+    .limit(limit)
+    .offset(offset);
+
+  return {
+    requests: results.map((r) => ({
+      id: r.id,
+      sequence: r.sequence ?? 1,
+      model: r.model,
+      statusCode: r.statusCode,
+      costUsd: r.costUsd,
+      createdAt: r.createdAt,
+      inputTokens: r.inputTokens,
+      outputTokens: r.outputTokens,
+      errorMessage: r.errorMessage,
+    })),
+    total,
+  };
+}

+ 3 - 0
src/repository/usage-logs.ts

@@ -28,6 +28,7 @@ export interface UsageLogRow {
   id: number;
   createdAt: Date | null;
   sessionId: string | null; // Session ID
+  requestSequence: number | null; // Request Sequence(Session 内请求序号)
   userName: string;
   keyName: string;
   providerName: string | null; // 改为可选:被拦截的请求没有 provider
@@ -238,6 +239,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
       id: messageRequest.id,
       createdAt: messageRequest.createdAt,
       sessionId: messageRequest.sessionId, // Session ID
+      requestSequence: messageRequest.requestSequence, // Request Sequence
       userName: users.name,
       keyName: keysTable.name,
       providerName: providers.name, // 被拦截的请求为 null
@@ -280,6 +282,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
 
     return {
       ...row,
+      requestSequence: row.requestSequence ?? null,
       totalTokens: totalRowTokens,
       cacheCreation5mInputTokens: row.cacheCreation5mInputTokens,
       cacheCreation1hInputTokens: row.cacheCreation1hInputTokens,

+ 6 - 0
src/types/message.ts

@@ -167,6 +167,9 @@ export interface MessageRequest {
   // Session ID(用于会话粘性和日志追踪)
   sessionId?: string;
 
+  // Request Sequence(Session 内请求序号)
+  requestSequence?: number;
+
   // 上游决策链(记录尝试的供应商列表)
   providerChain?: ProviderChainItem[];
 
@@ -219,6 +222,9 @@ export interface CreateMessageRequestData {
   // Session ID(用于会话粘性和日志追踪)
   session_id?: string;
 
+  // Request Sequence(Session 内请求序号,用于区分同一 Session 的不同请求)
+  request_sequence?: number;
+
   // 上游决策链
   provider_chain?: ProviderChainItem[];