Просмотр исходного кода

feat: web mcp (#241)

* feat: add mcp pannel

* fix: log detail

* feat: channel sets support

* feat: model set

* fix: pnpm version

* fix: build web
zijiren 10 месяцев назад
Родитель
Сommit
88cb54a4eb
49 измененных файлов с 6485 добавлено и 1132 удалено
  1. 12 0
      core/controller/group.go
  2. 16 35
      core/controller/model.go
  3. 3 11
      core/controller/modelconfig.go
  4. 1 3
      core/controller/relay-controller.go
  5. 73 98
      core/docs/docs.go
  6. 73 98
      core/docs/swagger.json
  7. 44 56
      core/docs/swagger.yaml
  8. 3 0
      core/model/groupmodel.go
  9. 9 5
      core/model/modelconfig.go
  10. 4 1
      core/model/publicmcp.go
  11. 1 2
      core/router/api.go
  12. 0 0
      mcp-servers/amap/main.go
  13. 1853 0
      web/mcp.txt
  14. 9 4
      web/package.json
  15. 253 286
      web/pnpm-lock.yaml
  16. 165 2
      web/public/locales/en/translation.json
  17. 195 33
      web/public/locales/zh/translation.json
  18. 7 1
      web/src/api/log.ts
  19. 145 0
      web/src/api/mcp.ts
  20. 42 19
      web/src/api/model.ts
  21. 29 0
      web/src/components/common/CopyButton.tsx
  22. 9 0
      web/src/components/layout/SideBar.tsx
  23. 17 3
      web/src/components/select/MultiSelectCombobox.tsx
  24. 52 0
      web/src/components/ui/tabs.tsx
  25. 24 0
      web/src/components/ui/textarea.tsx
  26. 177 0
      web/src/components/ui/use-toast.tsx
  27. 17 2
      web/src/feature/channel/components/ChannelDialog.tsx
  28. 124 20
      web/src/feature/channel/components/ChannelForm.tsx
  29. 25 1
      web/src/feature/channel/components/ChannelTable.tsx
  30. 160 0
      web/src/feature/log/components/ExpandedLogContent.tsx
  31. 3 89
      web/src/feature/log/components/LogTable.tsx
  32. 24 0
      web/src/feature/log/hooks.ts
  33. 6 0
      web/src/feature/model/components/ModelDialog.tsx
  34. 197 14
      web/src/feature/model/components/ModelForm.tsx
  35. 377 277
      web/src/feature/model/components/ModelTable.tsx
  36. 100 67
      web/src/feature/model/hooks.ts
  37. 217 0
      web/src/pages/mcp/components/EmbedMCP.tsx
  38. 969 0
      web/src/pages/mcp/components/MCPConfig.tsx
  39. 516 0
      web/src/pages/mcp/components/MCPList.tsx
  40. 102 0
      web/src/pages/mcp/components/config/OpenAPIConfig.tsx
  41. 349 0
      web/src/pages/mcp/components/config/ProxyConfig.tsx
  42. 37 0
      web/src/pages/mcp/page.tsx
  43. 5 0
      web/src/routes/config.tsx
  44. 1 0
      web/src/routes/constants.ts
  45. 6 3
      web/src/types/channel.ts
  46. 4 0
      web/src/types/model.ts
  47. 3 2
      web/src/validation/channel.ts
  48. 6 0
      web/src/validation/model.ts
  49. 21 0
      web/tailwind.config.js

+ 12 - 0
core/controller/group.go

@@ -417,6 +417,12 @@ type SaveGroupModelConfigRequest struct {
 	OverridePrice bool               `json:"override_price"`
 	ImagePrices   map[string]float64 `json:"image_prices"`
 	Price         model.Price        `json:"price"`
+
+	OverrideRetryTimes bool  `json:"override_retry_times"`
+	RetryTimes         int64 `json:"retry_times"`
+
+	OverrideForceSaveDetail bool `json:"override_force_save_detail"`
+	ForceSaveDetail         bool `json:"force_save_detail"`
 }
 
 func (r *SaveGroupModelConfigRequest) ToGroupModelConfig(groupID string) model.GroupModelConfig {
@@ -431,6 +437,12 @@ func (r *SaveGroupModelConfigRequest) ToGroupModelConfig(groupID string) model.G
 		OverridePrice: r.OverridePrice,
 		ImagePrices:   r.ImagePrices,
 		Price:         r.Price,
+
+		OverrideRetryTimes: r.OverrideRetryTimes,
+		RetryTimes:         r.RetryTimes,
+
+		OverrideForceSaveDetail: r.OverrideForceSaveDetail,
+		ForceSaveDetail:         r.ForceSaveDetail,
 	}
 }
 

+ 16 - 35
core/controller/model.go

@@ -258,57 +258,38 @@ func newEnabledModelChannel(ch *model.Channel) EnabledModelChannel {
 	}
 }
 
-// EnabledModelChannels godoc
+// EnabledModelSets godoc
 //
-//	@Summary		Get enabled models and channels
-//	@Description	Returns a list of enabled models
+//	@Summary		Get enabled models and channels sets
+//	@Description	Returns a list of enabled models and channels sets
 //	@Tags			model
 //	@Produce		json
 //	@Security		ApiKeyAuth
 //	@Success		200	{object}	middleware.APIResponse{data=map[string]map[string][]EnabledModelChannel}
-//	@Router			/api/models/channel [get]
-func EnabledModelChannels(c *gin.Context) {
+//	@Router			/api/models/sets [get]
+func EnabledModelSets(c *gin.Context) {
 	raw := model.LoadModelCaches().EnabledModel2ChannelsBySet
 	result := make(map[string]map[string][]EnabledModelChannel)
 
+	// First iterate through sets to get all models
+	for _, modelChannels := range raw {
+		for model := range modelChannels {
+			if _, exists := result[model]; !exists {
+				result[model] = make(map[string][]EnabledModelChannel)
+			}
+		}
+	}
+
+	// Then populate the channels for each model and set
 	for set, modelChannels := range raw {
-		result[set] = make(map[string][]EnabledModelChannel)
 		for model, channels := range modelChannels {
 			chs := make([]EnabledModelChannel, len(channels))
 			for i, channel := range channels {
 				chs[i] = newEnabledModelChannel(channel)
 			}
-			result[set][model] = chs
+			result[model][set] = chs
 		}
 	}
 
 	middleware.SuccessResponse(c, result)
 }
-
-// EnabledModelChannelsSet godoc
-//
-//	@Summary		Get enabled models and channels by set
-//	@Description	Returns a list of enabled models and channels by set
-//	@Tags			model
-//	@Produce		json
-//	@Security		ApiKeyAuth
-//	@Param			set	path		string	true	"Models set"
-//	@Success		200	{object}	middleware.APIResponse{data=map[string][]EnabledModelChannel}
-//	@Router			/api/models/channel/{set} [get]
-func EnabledModelChannelsSet(c *gin.Context) {
-	set := c.Param("set")
-	if set == "" {
-		middleware.ErrorResponse(c, http.StatusBadRequest, "set is required")
-		return
-	}
-	raw := model.LoadModelCaches().EnabledModel2ChannelsBySet[set]
-	result := make(map[string][]EnabledModelChannel, len(raw))
-	for model, channels := range raw {
-		chs := make([]EnabledModelChannel, len(channels))
-		for i, channel := range channels {
-			chs[i] = newEnabledModelChannel(channel)
-		}
-		result[model] = chs
-	}
-	middleware.SuccessResponse(c, result)
-}

+ 3 - 11
core/controller/modelconfig.go

@@ -116,11 +116,7 @@ func SearchModelConfigs(c *gin.Context) {
 	})
 }
 
-type SaveModelConfigsRequest struct {
-	CreatedAt int64 `json:"created_at"`
-	UpdatedAt int64 `json:"updated_at"`
-	model.ModelConfig
-}
+type SaveModelConfigsRequest = model.ModelConfig
 
 // SaveModelConfigs godoc
 //
@@ -138,11 +134,7 @@ func SaveModelConfigs(c *gin.Context) {
 		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
 		return
 	}
-	modelConfigs := make([]model.ModelConfig, len(configs))
-	for i, config := range configs {
-		modelConfigs[i] = config.ModelConfig
-	}
-	err := model.SaveModelConfigs(modelConfigs)
+	err := model.SaveModelConfigs(configs)
 	if err != nil {
 		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
 		return
@@ -166,7 +158,7 @@ func SaveModelConfig(c *gin.Context) {
 		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
 		return
 	}
-	err := model.SaveModelConfig(config.ModelConfig)
+	err := model.SaveModelConfig(config)
 	if err != nil {
 		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
 		return

+ 1 - 3
core/controller/relay-controller.go

@@ -280,9 +280,7 @@ func recordResult(
 
 	var detail *model.RequestDetail
 	firstByteAt := result.Detail.FirstByteAt
-	if code == http.StatusOK && !config.GetSaveAllLogDetail() {
-		detail = nil
-	} else {
+	if config.GetSaveAllLogDetail() || meta.ModelConfig.ForceSaveDetail || code != http.StatusOK {
 		detail = &model.RequestDetail{
 			RequestBody:  result.Detail.RequestBody,
 			ResponseBody: result.Detail.ResponseBody,

+ 73 - 98
core/docs/docs.go

@@ -4586,104 +4586,6 @@ const docTemplate = `{
                 }
             }
         },
-        "/api/models/channel": {
-            "get": {
-                "security": [
-                    {
-                        "ApiKeyAuth": []
-                    }
-                ],
-                "description": "Returns a list of enabled models",
-                "produces": [
-                    "application/json"
-                ],
-                "tags": [
-                    "model"
-                ],
-                "summary": "Get enabled models and channels",
-                "responses": {
-                    "200": {
-                        "description": "OK",
-                        "schema": {
-                            "allOf": [
-                                {
-                                    "$ref": "#/definitions/middleware.APIResponse"
-                                },
-                                {
-                                    "type": "object",
-                                    "properties": {
-                                        "data": {
-                                            "type": "object",
-                                            "additionalProperties": {
-                                                "type": "object",
-                                                "additionalProperties": {
-                                                    "type": "array",
-                                                    "items": {
-                                                        "$ref": "#/definitions/controller.EnabledModelChannel"
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            ]
-                        }
-                    }
-                }
-            }
-        },
-        "/api/models/channel/{set}": {
-            "get": {
-                "security": [
-                    {
-                        "ApiKeyAuth": []
-                    }
-                ],
-                "description": "Returns a list of enabled models and channels by set",
-                "produces": [
-                    "application/json"
-                ],
-                "tags": [
-                    "model"
-                ],
-                "summary": "Get enabled models and channels by set",
-                "parameters": [
-                    {
-                        "type": "string",
-                        "description": "Models set",
-                        "name": "set",
-                        "in": "path",
-                        "required": true
-                    }
-                ],
-                "responses": {
-                    "200": {
-                        "description": "OK",
-                        "schema": {
-                            "allOf": [
-                                {
-                                    "$ref": "#/definitions/middleware.APIResponse"
-                                },
-                                {
-                                    "type": "object",
-                                    "properties": {
-                                        "data": {
-                                            "type": "object",
-                                            "additionalProperties": {
-                                                "type": "array",
-                                                "items": {
-                                                    "$ref": "#/definitions/controller.EnabledModelChannel"
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            ]
-                        }
-                    }
-                }
-            }
-        },
         "/api/models/default": {
             "get": {
                 "security": [
@@ -4903,6 +4805,52 @@ const docTemplate = `{
                 }
             }
         },
+        "/api/models/sets": {
+            "get": {
+                "security": [
+                    {
+                        "ApiKeyAuth": []
+                    }
+                ],
+                "description": "Returns a list of enabled models and channels sets",
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "model"
+                ],
+                "summary": "Get enabled models and channels sets",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "allOf": [
+                                {
+                                    "$ref": "#/definitions/middleware.APIResponse"
+                                },
+                                {
+                                    "type": "object",
+                                    "properties": {
+                                        "data": {
+                                            "type": "object",
+                                            "additionalProperties": {
+                                                "type": "object",
+                                                "additionalProperties": {
+                                                    "type": "array",
+                                                    "items": {
+                                                        "$ref": "#/definitions/controller.EnabledModelChannel"
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        },
         "/api/monitor/": {
             "get": {
                 "security": [
@@ -8091,6 +8039,9 @@ const docTemplate = `{
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",
@@ -8490,6 +8441,9 @@ const docTemplate = `{
         "controller.SaveGroupModelConfigRequest": {
             "type": "object",
             "properties": {
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "type": "object",
                     "additionalProperties": {
@@ -8499,15 +8453,24 @@ const docTemplate = `{
                 "model": {
                     "type": "string"
                 },
+                "override_force_save_detail": {
+                    "type": "boolean"
+                },
                 "override_limit": {
                     "type": "boolean"
                 },
                 "override_price": {
                     "type": "boolean"
                 },
+                "override_retry_times": {
+                    "type": "boolean"
+                },
                 "price": {
                     "$ref": "#/definitions/model.Price"
                 },
+                "retry_times": {
+                    "type": "integer"
+                },
                 "rpm": {
                     "type": "integer"
                 },
@@ -8529,6 +8492,9 @@ const docTemplate = `{
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",
@@ -9604,6 +9570,9 @@ const docTemplate = `{
         "model.GroupModelConfig": {
             "type": "object",
             "properties": {
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "group_id": {
                     "type": "string"
                 },
@@ -9616,6 +9585,9 @@ const docTemplate = `{
                 "model": {
                     "type": "string"
                 },
+                "override_force_save_detail": {
+                    "type": "boolean"
+                },
                 "override_limit": {
                     "type": "boolean"
                 },
@@ -9957,6 +9929,9 @@ const docTemplate = `{
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",

+ 73 - 98
core/docs/swagger.json

@@ -4577,104 +4577,6 @@
                 }
             }
         },
-        "/api/models/channel": {
-            "get": {
-                "security": [
-                    {
-                        "ApiKeyAuth": []
-                    }
-                ],
-                "description": "Returns a list of enabled models",
-                "produces": [
-                    "application/json"
-                ],
-                "tags": [
-                    "model"
-                ],
-                "summary": "Get enabled models and channels",
-                "responses": {
-                    "200": {
-                        "description": "OK",
-                        "schema": {
-                            "allOf": [
-                                {
-                                    "$ref": "#/definitions/middleware.APIResponse"
-                                },
-                                {
-                                    "type": "object",
-                                    "properties": {
-                                        "data": {
-                                            "type": "object",
-                                            "additionalProperties": {
-                                                "type": "object",
-                                                "additionalProperties": {
-                                                    "type": "array",
-                                                    "items": {
-                                                        "$ref": "#/definitions/controller.EnabledModelChannel"
-                                                    }
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            ]
-                        }
-                    }
-                }
-            }
-        },
-        "/api/models/channel/{set}": {
-            "get": {
-                "security": [
-                    {
-                        "ApiKeyAuth": []
-                    }
-                ],
-                "description": "Returns a list of enabled models and channels by set",
-                "produces": [
-                    "application/json"
-                ],
-                "tags": [
-                    "model"
-                ],
-                "summary": "Get enabled models and channels by set",
-                "parameters": [
-                    {
-                        "type": "string",
-                        "description": "Models set",
-                        "name": "set",
-                        "in": "path",
-                        "required": true
-                    }
-                ],
-                "responses": {
-                    "200": {
-                        "description": "OK",
-                        "schema": {
-                            "allOf": [
-                                {
-                                    "$ref": "#/definitions/middleware.APIResponse"
-                                },
-                                {
-                                    "type": "object",
-                                    "properties": {
-                                        "data": {
-                                            "type": "object",
-                                            "additionalProperties": {
-                                                "type": "array",
-                                                "items": {
-                                                    "$ref": "#/definitions/controller.EnabledModelChannel"
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            ]
-                        }
-                    }
-                }
-            }
-        },
         "/api/models/default": {
             "get": {
                 "security": [
@@ -4894,6 +4796,52 @@
                 }
             }
         },
+        "/api/models/sets": {
+            "get": {
+                "security": [
+                    {
+                        "ApiKeyAuth": []
+                    }
+                ],
+                "description": "Returns a list of enabled models and channels sets",
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "model"
+                ],
+                "summary": "Get enabled models and channels sets",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "allOf": [
+                                {
+                                    "$ref": "#/definitions/middleware.APIResponse"
+                                },
+                                {
+                                    "type": "object",
+                                    "properties": {
+                                        "data": {
+                                            "type": "object",
+                                            "additionalProperties": {
+                                                "type": "object",
+                                                "additionalProperties": {
+                                                    "type": "array",
+                                                    "items": {
+                                                        "$ref": "#/definitions/controller.EnabledModelChannel"
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        },
         "/api/monitor/": {
             "get": {
                 "security": [
@@ -8082,6 +8030,9 @@
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",
@@ -8481,6 +8432,9 @@
         "controller.SaveGroupModelConfigRequest": {
             "type": "object",
             "properties": {
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "type": "object",
                     "additionalProperties": {
@@ -8490,15 +8444,24 @@
                 "model": {
                     "type": "string"
                 },
+                "override_force_save_detail": {
+                    "type": "boolean"
+                },
                 "override_limit": {
                     "type": "boolean"
                 },
                 "override_price": {
                     "type": "boolean"
                 },
+                "override_retry_times": {
+                    "type": "boolean"
+                },
                 "price": {
                     "$ref": "#/definitions/model.Price"
                 },
+                "retry_times": {
+                    "type": "integer"
+                },
                 "rpm": {
                     "type": "integer"
                 },
@@ -8520,6 +8483,9 @@
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",
@@ -9595,6 +9561,9 @@
         "model.GroupModelConfig": {
             "type": "object",
             "properties": {
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "group_id": {
                     "type": "string"
                 },
@@ -9607,6 +9576,9 @@
                 "model": {
                     "type": "string"
                 },
+                "override_force_save_detail": {
+                    "type": "boolean"
+                },
                 "override_limit": {
                     "type": "boolean"
                 },
@@ -9948,6 +9920,9 @@
                 "exclude_from_tests": {
                     "type": "boolean"
                 },
+                "force_save_detail": {
+                    "type": "boolean"
+                },
                 "image_prices": {
                     "description": "map[size]price_per_image",
                     "type": "object",

+ 44 - 56
core/docs/swagger.yaml

@@ -97,6 +97,8 @@ definitions:
         type: string
       exclude_from_tests:
         type: boolean
+      force_save_detail:
+        type: boolean
       image_prices:
         additionalProperties:
           type: number
@@ -359,18 +361,26 @@ definitions:
     type: object
   controller.SaveGroupModelConfigRequest:
     properties:
+      force_save_detail:
+        type: boolean
       image_prices:
         additionalProperties:
           type: number
         type: object
       model:
         type: string
+      override_force_save_detail:
+        type: boolean
       override_limit:
         type: boolean
       override_price:
         type: boolean
+      override_retry_times:
+        type: boolean
       price:
         $ref: '#/definitions/model.Price'
+      retry_times:
+        type: integer
       rpm:
         type: integer
       tpm:
@@ -385,6 +395,8 @@ definitions:
         type: string
       exclude_from_tests:
         type: boolean
+      force_save_detail:
+        type: boolean
       image_prices:
         additionalProperties:
           type: number
@@ -1132,6 +1144,8 @@ definitions:
     - GroupMCPTypeOpenAPI
   model.GroupModelConfig:
     properties:
+      force_save_detail:
+        type: boolean
       group_id:
         type: string
       image_prices:
@@ -1140,6 +1154,8 @@ definitions:
         type: object
       model:
         type: string
+      override_force_save_detail:
+        type: boolean
       override_limit:
         type: boolean
       override_price:
@@ -1368,6 +1384,8 @@ definitions:
         type: string
       exclude_from_tests:
         type: boolean
+      force_save_detail:
+        type: boolean
       image_prices:
         additionalProperties:
           type: number
@@ -4697,62 +4715,6 @@ paths:
       summary: Get channel builtin models by type
       tags:
       - model
-  /api/models/channel:
-    get:
-      description: Returns a list of enabled models
-      produces:
-      - application/json
-      responses:
-        "200":
-          description: OK
-          schema:
-            allOf:
-            - $ref: '#/definitions/middleware.APIResponse'
-            - properties:
-                data:
-                  additionalProperties:
-                    additionalProperties:
-                      items:
-                        $ref: '#/definitions/controller.EnabledModelChannel'
-                      type: array
-                    type: object
-                  type: object
-              type: object
-      security:
-      - ApiKeyAuth: []
-      summary: Get enabled models and channels
-      tags:
-      - model
-  /api/models/channel/{set}:
-    get:
-      description: Returns a list of enabled models and channels by set
-      parameters:
-      - description: Models set
-        in: path
-        name: set
-        required: true
-        type: string
-      produces:
-      - application/json
-      responses:
-        "200":
-          description: OK
-          schema:
-            allOf:
-            - $ref: '#/definitions/middleware.APIResponse'
-            - properties:
-                data:
-                  additionalProperties:
-                    items:
-                      $ref: '#/definitions/controller.EnabledModelChannel'
-                    type: array
-                  type: object
-              type: object
-      security:
-      - ApiKeyAuth: []
-      summary: Get enabled models and channels by set
-      tags:
-      - model
   /api/models/default:
     get:
       description: Returns a list of channel default models and mapping
@@ -4877,6 +4839,32 @@ paths:
       summary: Get enabled models by set
       tags:
       - model
+  /api/models/sets:
+    get:
+      description: Returns a list of enabled models and channels sets
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            allOf:
+            - $ref: '#/definitions/middleware.APIResponse'
+            - properties:
+                data:
+                  additionalProperties:
+                    additionalProperties:
+                      items:
+                        $ref: '#/definitions/controller.EnabledModelChannel'
+                      type: array
+                    type: object
+                  type: object
+              type: object
+      security:
+      - ApiKeyAuth: []
+      summary: Get enabled models and channels sets
+      tags:
+      - model
   /api/monitor/:
     delete:
       description: Clears all model errors

+ 3 - 0
core/model/groupmodel.go

@@ -26,6 +26,9 @@ type GroupModelConfig struct {
 
 	OverrideRetryTimes bool  `json:"override_retry_times"`
 	RetryTimes         int64 `json:"retry_times"`
+
+	OverrideForceSaveDetail bool `json:"override_force_save_detail"`
+	ForceSaveDetail         bool `json:"force_save_detail"`
 }
 
 func (g *GroupModelConfig) BeforeSave(_ *gorm.DB) (err error) {

+ 9 - 5
core/model/modelconfig.go

@@ -33,11 +33,12 @@ type ModelConfig struct {
 	// map[size]map[quality]price_per_image
 	ImageQualityPrices map[string]map[string]float64 `gorm:"serializer:fastjson;type:text" json:"image_quality_prices,omitempty"`
 	// map[size]price_per_image
-	ImagePrices  map[string]float64 `gorm:"serializer:fastjson;type:text" json:"image_prices,omitempty"`
-	Price        Price              `gorm:"embedded"                      json:"price,omitempty"`
-	RetryTimes   int64              `                                     json:"retry_times,omitempty"`
-	Timeout      int64              `                                     json:"timeout,omitempty"`
-	MaxErrorRate float64            `                                     json:"max_error_rate,omitempty"`
+	ImagePrices     map[string]float64 `gorm:"serializer:fastjson;type:text" json:"image_prices,omitempty"`
+	Price           Price              `gorm:"embedded"                      json:"price,omitempty"`
+	RetryTimes      int64              `                                     json:"retry_times,omitempty"`
+	Timeout         int64              `                                     json:"timeout,omitempty"`
+	MaxErrorRate    float64            `                                     json:"max_error_rate,omitempty"`
+	ForceSaveDetail bool               `                                     json:"force_save_detail,omitempty"`
 }
 
 func (c *ModelConfig) BeforeSave(_ *gorm.DB) (err error) {
@@ -77,6 +78,9 @@ func (c *ModelConfig) LoadFromGroupModelConfig(groupModelConfig GroupModelConfig
 	if groupModelConfig.OverrideRetryTimes {
 		newC.RetryTimes = groupModelConfig.RetryTimes
 	}
+	if groupModelConfig.OverrideForceSaveDetail {
+		newC.ForceSaveDetail = groupModelConfig.ForceSaveDetail
+	}
 	return newC
 }
 

+ 4 - 1
core/model/publicmcp.go

@@ -143,7 +143,7 @@ type PublicMCP struct {
 	EmbedConfig            *MCPEmbeddingConfig     `gorm:"serializer:fastjson;type:text" json:"embed_config,omitempty"`
 }
 
-func (p *PublicMCP) BeforeSave(_ *gorm.DB) error {
+func (p *PublicMCP) BeforeCreate(_ *gorm.DB) error {
 	if err := validateMCPID(p.ID); err != nil {
 		return err
 	}
@@ -151,7 +151,10 @@ func (p *PublicMCP) BeforeSave(_ *gorm.DB) error {
 	if p.Status == 0 {
 		p.Status = PublicMCPStatusEnabled
 	}
+	return nil
+}
 
+func (p *PublicMCP) BeforeSave(_ *gorm.DB) error {
 	if p.OpenAPIConfig != nil {
 		config := p.OpenAPIConfig
 		if config.OpenAPISpec != "" {

+ 1 - 2
core/router/api.go

@@ -28,8 +28,7 @@ func SetAPIRouter(router *gin.Engine) {
 			modelsRoute.GET("/builtin/channel/:type", controller.ChannelBuiltinModelsByType)
 			modelsRoute.GET("/enabled", controller.EnabledModels)
 			modelsRoute.GET("/enabled/:set", controller.EnabledModelsSet)
-			modelsRoute.GET("/channel", controller.EnabledModelChannels)
-			modelsRoute.GET("/channel/:set", controller.EnabledModelChannelsSet)
+			modelsRoute.GET("/sets", controller.EnabledModelSets)
 			modelsRoute.GET("/default", controller.ChannelDefaultModelsAndMapping)
 			modelsRoute.GET("/default/:type", controller.ChannelDefaultModelsAndMappingByType)
 		}

+ 0 - 0
mcp-servers/amap/openapi.go → mcp-servers/amap/main.go


+ 1853 - 0
web/mcp.txt

@@ -0,0 +1,1853 @@
+File: core/controller/mcp/publicmcp.go
+```go
+package controller
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bytedance/sonic"
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/common/config"
+	"github.com/labring/aiproxy/core/controller/utils"
+	"github.com/labring/aiproxy/core/middleware"
+	"github.com/labring/aiproxy/core/model"
+)
+
+type MCPEndpoint struct {
+	Host           string `json:"host"`
+	SSE            string `json:"sse"`
+	StreamableHTTP string `json:"streamable_http"`
+}
+
+type PublicMCPResponse struct {
+	model.PublicMCP
+	Endpoints MCPEndpoint `json:"endpoints"`
+}
+
+func (mcp *PublicMCPResponse) MarshalJSON() ([]byte, error) {
+	type Alias PublicMCPResponse
+	a := &struct {
+		*Alias
+		CreatedAt int64 `json:"created_at"`
+		UpdateAt  int64 `json:"update_at"`
+	}{
+		Alias:     (*Alias)(mcp),
+		CreatedAt: mcp.CreatedAt.UnixMilli(),
+		UpdateAt:  mcp.UpdateAt.UnixMilli(),
+	}
+	return sonic.Marshal(a)
+}
+
+func NewPublicMCPResponse(host string, mcp model.PublicMCP) PublicMCPResponse {
+	ep := MCPEndpoint{}
+	switch mcp.Type {
+	case model.PublicMCPTypeProxySSE,
+		model.PublicMCPTypeProxyStreamable,
+		model.PublicMCPTypeEmbed,
+		model.PublicMCPTypeOpenAPI:
+		publicMCPHost := config.GetPublicMCPHost()
+		if publicMCPHost == "" {
+			ep.Host = host
+			ep.SSE = fmt.Sprintf("/mcp/public/%s/sse", mcp.ID)
+			ep.StreamableHTTP = "/mcp/public/" + mcp.ID
+		} else {
+			ep.Host = fmt.Sprintf("%s.%s", mcp.ID, publicMCPHost)
+			ep.SSE = "/sse"
+			ep.StreamableHTTP = "/mcp"
+		}
+	case model.PublicMCPTypeDocs:
+	}
+	return PublicMCPResponse{
+		PublicMCP: mcp,
+		Endpoints: ep,
+	}
+}
+
+func NewPublicMCPResponses(host string, mcps []model.PublicMCP) []PublicMCPResponse {
+	responses := make([]PublicMCPResponse, len(mcps))
+	for i, mcp := range mcps {
+		responses[i] = NewPublicMCPResponse(host, mcp)
+	}
+	return responses
+}
+
+// GetPublicMCPs godoc
+//
+//	@Summary		Get MCPs
+//	@Description	Get a list of MCPs with pagination and filtering
+//	@Tags			mcp
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			page		query		int		false	"Page number"
+//	@Param			per_page	query		int		false	"Items per page"
+//	@Param			type		query		string	false	"MCP type"
+//	@Param			keyword		query		string	false	"Search keyword"
+//	@Param			status		query		int		false	"MCP status"
+//	@Success		200			{object}	middleware.APIResponse{data=[]PublicMCPResponse}
+//	@Router			/api/mcp/public/ [get]
+func GetPublicMCPs(c *gin.Context) {
+	page, perPage := utils.ParsePageParams(c)
+	mcpType := model.PublicMCPType(c.Query("type"))
+	keyword := c.Query("keyword")
+	status, _ := strconv.Atoi(c.Query("status"))
+
+	if status == 0 {
+		status = int(model.PublicMCPStatusEnabled)
+	}
+
+	mcps, total, err := model.GetPublicMCPs(
+		page,
+		perPage,
+		mcpType,
+		keyword,
+		model.PublicMCPStatus(status),
+	)
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, gin.H{
+		"mcps":  NewPublicMCPResponses(c.Request.Host, mcps),
+		"total": total,
+	})
+}
+
+// GetAllPublicMCPs godoc
+//
+//	@Summary		Get all MCPs
+//	@Description	Get all MCPs with filtering
+//	@Tags			mcp
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			status	query		int	false	"MCP status"
+//	@Success		200		{object}	middleware.APIResponse{data=[]PublicMCPResponse}
+//	@Router			/api/mcp/public/all [get]
+func GetAllPublicMCPs(c *gin.Context) {
+	status, _ := strconv.Atoi(c.Query("status"))
+
+	if status == 0 {
+		status = int(model.PublicMCPStatusEnabled)
+	}
+
+	mcps, err := model.GetAllPublicMCPs(model.PublicMCPStatus(status))
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+	middleware.SuccessResponse(c, NewPublicMCPResponses(c.Request.Host, mcps))
+}
+
+// GetPublicMCPByIDHandler godoc
+//
+//	@Summary		Get MCP by ID
+//	@Description	Get a specific MCP by its ID
+//	@Tags			mcp
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id	path		string	true	"MCP ID"
+//	@Success		200	{object}	middleware.APIResponse{data=PublicMCPResponse}
+//	@Router			/api/mcp/public/{id} [get]
+func GetPublicMCPByIDHandler(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID is required")
+		return
+	}
+
+	mcp, err := model.GetPublicMCPByID(id)
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusNotFound, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, NewPublicMCPResponse(c.Request.Host, mcp))
+}
+
+// CreatePublicMCP godoc
+//
+//	@Summary		Create MCP
+//	@Description	Create a new MCP
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			mcp	body		model.PublicMCP	true	"MCP object"
+//	@Success		200	{object}	middleware.APIResponse{data=PublicMCPResponse}
+//	@Router			/api/mcp/public/ [post]
+func CreatePublicMCP(c *gin.Context) {
+	var mcp model.PublicMCP
+	if err := c.ShouldBindJSON(&mcp); err != nil {
+		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	if err := model.CreatePublicMCP(&mcp); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, NewPublicMCPResponse(c.Request.Host, mcp))
+}
+
+type UpdatePublicMCPStatusRequest struct {
+	Status model.PublicMCPStatus `json:"status"`
+}
+
+// UpdatePublicMCPStatus godoc
+//
+//	@Summary		Update MCP status
+//	@Description	Update the status of an MCP
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id		path		string							true	"MCP ID"
+//	@Param			status	body		UpdatePublicMCPStatusRequest	true	"MCP status"
+//	@Success		200		{object}	middleware.APIResponse
+//	@Router			/api/mcp/public/{id}/status [post]
+func UpdatePublicMCPStatus(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID is required")
+		return
+	}
+
+	var status UpdatePublicMCPStatusRequest
+	if err := c.ShouldBindJSON(&status); err != nil {
+		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	if err := model.UpdatePublicMCPStatus(id, status.Status); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, nil)
+}
+
+// UpdatePublicMCP godoc
+//
+//	@Summary		Update MCP
+//	@Description	Update an existing MCP
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id	path		string			true	"MCP ID"
+//	@Param			mcp	body		model.PublicMCP	true	"MCP object"
+//	@Success		200	{object}	middleware.APIResponse{data=PublicMCPResponse}
+//	@Router			/api/mcp/public/{id} [put]
+func UpdatePublicMCP(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID is required")
+		return
+	}
+
+	var mcp model.PublicMCP
+	if err := c.ShouldBindJSON(&mcp); err != nil {
+		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	mcp.ID = id
+
+	if err := model.UpdatePublicMCP(&mcp); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, NewPublicMCPResponse(c.Request.Host, mcp))
+}
+
+// DeletePublicMCP godoc
+//
+//	@Summary		Delete MCP
+//	@Description	Delete an MCP by ID
+//	@Tags			mcp
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id	path		string	true	"MCP ID"
+//	@Success		200	{object}	middleware.APIResponse
+//	@Router			/api/mcp/public/{id} [delete]
+func DeletePublicMCP(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID is required")
+		return
+	}
+
+	if err := model.DeletePublicMCP(id); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, nil)
+}
+
+// GetGroupPublicMCPReusingParam godoc
+//
+//	@Summary		Get group MCP reusing parameters
+//	@Description	Get reusing parameters for a specific group and MCP
+//	@Tags			mcp
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id		path		string	true	"MCP ID"
+//	@Param			group	path		string	true	"Group ID"
+//	@Success		200		{object}	middleware.APIResponse{data=model.PublicMCPReusingParam}
+//	@Router			/api/mcp/public/{id}/group/{group}/params [get]
+func GetGroupPublicMCPReusingParam(c *gin.Context) {
+	mcpID := c.Param("id")
+	groupID := c.Param("group")
+
+	if mcpID == "" || groupID == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID and Group ID are required")
+		return
+	}
+
+	param, err := model.GetPublicMCPReusingParam(mcpID, groupID)
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusNotFound, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, param)
+}
+
+// SaveGroupPublicMCPReusingParam godoc
+//
+//	@Summary		Create or update group MCP reusing parameters
+//	@Description	Create or update reusing parameters for a specific group and MCP
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			id		path		string						true	"MCP ID"
+//	@Param			group	path		string						true	"Group ID"
+//	@Param			params	body		model.PublicMCPReusingParam	true	"Reusing parameters"
+//	@Success		200		{object}	middleware.APIResponse
+//	@Router			/api/mcp/public/{id}/group/{group}/params [post]
+func SaveGroupPublicMCPReusingParam(c *gin.Context) {
+	mcpID := c.Param("id")
+	groupID := c.Param("group")
+
+	if mcpID == "" || groupID == "" {
+		middleware.ErrorResponse(c, http.StatusBadRequest, "MCP ID and Group ID are required")
+		return
+	}
+
+	var param model.PublicMCPReusingParam
+	if err := c.ShouldBindJSON(&param); err != nil {
+		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	param.MCPID = mcpID
+	param.GroupID = groupID
+
+	if err := model.SavePublicMCPReusingParam(&param); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, param)
+}
+
+```
+
+File: mcp-servers/server.go
+```go
+package mcpservers
+
+import (
+	"context"
+	"encoding/json"
+	"runtime"
+
+	"github.com/bytedance/sonic"
+	"github.com/mark3labs/mcp-go/client/transport"
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+type Server interface {
+	HandleMessage(ctx context.Context, message json.RawMessage) mcp.JSONRPCMessage
+}
+
+type client2Server struct {
+	client transport.Interface
+}
+
+func (s *client2Server) HandleMessage(
+	ctx context.Context,
+	message json.RawMessage,
+) mcp.JSONRPCMessage {
+	methodNode, err := sonic.Get(message, "method")
+	if err != nil {
+		return CreateMCPErrorResponse(nil, mcp.PARSE_ERROR, err.Error())
+	}
+	method, err := methodNode.String()
+	if err != nil {
+		return CreateMCPErrorResponse(nil, mcp.PARSE_ERROR, err.Error())
+	}
+
+	switch method {
+	case "notifications/initialized":
+		req := mcp.JSONRPCNotification{}
+		err := sonic.Unmarshal(message, &req)
+		if err != nil {
+			return CreateMCPErrorResponse(nil, mcp.PARSE_ERROR, err.Error())
+		}
+		err = s.client.SendNotification(ctx, req)
+		if err != nil {
+			return CreateMCPErrorResponse(nil, mcp.PARSE_ERROR, err.Error())
+		}
+		return nil
+	default:
+		req := transport.JSONRPCRequest{}
+		err := sonic.Unmarshal(message, &req)
+		if err != nil {
+			return CreateMCPErrorResponse(nil, mcp.PARSE_ERROR, err.Error())
+		}
+		resp, err := s.client.SendRequest(ctx, req)
+		if err != nil {
+			return CreateMCPErrorResponse(nil, mcp.INTERNAL_ERROR, err.Error())
+		}
+		if resp.Error != nil {
+			return CreateMCPErrorResponse(
+				resp.ID,
+				resp.Error.Code,
+				resp.Error.Message,
+				resp.Error.Data,
+			)
+		}
+		return CreateMCPResultResponse(
+			resp.ID,
+			resp.Result,
+		)
+	}
+}
+
+func WrapMCPClient2Server(client transport.Interface) Server {
+	return &client2Server{client: client}
+}
+
+func WrapMCPClient2ServerWithCleanup(client transport.Interface) Server {
+	server := &client2Server{client: client}
+	_ = runtime.AddCleanup(server, func(client transport.Interface) {
+		_ = client.Close()
+	}, server.client)
+	return server
+}
+
+type JSONRPCNoErrorResponse struct {
+	JSONRPC string          `json:"jsonrpc"`
+	ID      mcp.RequestId   `json:"id"`
+	Result  json.RawMessage `json:"result"`
+}
+
+func CreateMCPResultResponse(
+	id any,
+	result json.RawMessage,
+) mcp.JSONRPCMessage {
+	return &JSONRPCNoErrorResponse{
+		JSONRPC: mcp.JSONRPC_VERSION,
+		ID:      mcp.NewRequestId(id),
+		Result:  result,
+	}
+}
+
+func CreateMCPErrorResponse(
+	id any,
+	code int,
+	message string,
+	data ...any,
+) mcp.JSONRPCMessage {
+	var d any
+	if len(data) > 0 {
+		d = data[0]
+	}
+	return mcp.JSONRPCError{
+		JSONRPC: mcp.JSONRPC_VERSION,
+		ID:      mcp.NewRequestId(id),
+		Error: struct {
+			Code    int    `json:"code"`
+			Message string `json:"message"`
+			Data    any    `json:"data,omitempty"`
+		}{
+			Code:    code,
+			Message: message,
+			Data:    d,
+		},
+	}
+}
+
+```
+
+File: core/controller/mcp/embedmcp.go
+```go
+package controller
+
+import (
+	"context"
+	"fmt"
+	"maps"
+	"net/http"
+	"net/url"
+	"slices"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"github.com/labring/aiproxy/core/mcpproxy"
+	"github.com/labring/aiproxy/core/middleware"
+	"github.com/labring/aiproxy/core/model"
+	mcpservers "github.com/labring/aiproxy/mcp-servers"
+	// init embed mcp
+	_ "github.com/labring/aiproxy/mcp-servers/mcpregister"
+	"github.com/mark3labs/mcp-go/mcp"
+)
+
+type EmbedMCPConfigTemplate struct {
+	Name        string `json:"name"`
+	Required    bool   `json:"required"`
+	Example     string `json:"example,omitempty"`
+	Description string `json:"description,omitempty"`
+}
+
+func newEmbedMCPConfigTemplate(template mcpservers.ConfigTemplate) EmbedMCPConfigTemplate {
+	return EmbedMCPConfigTemplate{
+		Name:        template.Name,
+		Required:    template.Required == mcpservers.ConfigRequiredTypeInitOnly,
+		Example:     template.Example,
+		Description: template.Description,
+	}
+}
+
+type EmbedMCPConfigTemplates = map[string]EmbedMCPConfigTemplate
+
+func newEmbedMCPConfigTemplates(templates mcpservers.ConfigTemplates) EmbedMCPConfigTemplates {
+	emcpTemplates := make(EmbedMCPConfigTemplates, len(templates))
+	for key, template := range templates {
+		emcpTemplates[key] = newEmbedMCPConfigTemplate(template)
+	}
+	return emcpTemplates
+}
+
+type EmbedMCP struct {
+	ID              string                  `json:"id"`
+	Enabled         bool                    `json:"enabled"`
+	Name            string                  `json:"name"`
+	Readme          string                  `json:"readme"`
+	Tags            []string                `json:"tags"`
+	ConfigTemplates EmbedMCPConfigTemplates `json:"config_templates"`
+}
+
+func newEmbedMCP(mcp *mcpservers.McpServer, enabled bool) *EmbedMCP {
+	emcp := &EmbedMCP{
+		ID:              mcp.ID,
+		Enabled:         enabled,
+		Name:            mcp.Name,
+		Readme:          mcp.Readme,
+		Tags:            mcp.Tags,
+		ConfigTemplates: newEmbedMCPConfigTemplates(mcp.ConfigTemplates),
+	}
+	return emcp
+}
+
+// GetEmbedMCPs godoc
+//
+//	@Summary		Get embed mcp
+//	@Description	Get embed mcp
+//	@Tags			embedmcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Success		200	{array}	EmbedMCP
+//	@Router			/api/embedmcp/ [get]
+func GetEmbedMCPs(c *gin.Context) {
+	embeds := mcpservers.Servers()
+	enabledMCPs, err := model.GetPublicMCPsEnabled(slices.Collect(maps.Keys(embeds)))
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	emcps := make([]*EmbedMCP, 0, len(embeds))
+	for _, mcp := range embeds {
+		emcps = append(emcps, newEmbedMCP(&mcp, slices.Contains(enabledMCPs, mcp.ID)))
+	}
+
+	middleware.SuccessResponse(c, emcps)
+}
+
+type SaveEmbedMCPRequest struct {
+	ID         string            `json:"id"`
+	Enabled    bool              `json:"enabled"`
+	InitConfig map[string]string `json:"init_config"`
+}
+
+func GetEmbedConfig(
+	ct mcpservers.ConfigTemplates,
+	initConfig map[string]string,
+) (*model.MCPEmbeddingConfig, error) {
+	reusingConfig := make(map[string]model.MCPEmbeddingReusingConfig)
+	embedConfig := &model.MCPEmbeddingConfig{
+		Init: initConfig,
+	}
+	for key, value := range ct {
+		switch value.Required {
+		case mcpservers.ConfigRequiredTypeInitOnly:
+			if v, ok := initConfig[key]; !ok || v == "" {
+				return nil, fmt.Errorf("config %s is required", key)
+			}
+		case mcpservers.ConfigRequiredTypeReusingOnly:
+			if _, ok := initConfig[key]; ok {
+				return nil, fmt.Errorf("config %s is provided, but it is not allowed", key)
+			}
+			reusingConfig[key] = model.MCPEmbeddingReusingConfig{
+				Name:        value.Name,
+				Description: value.Description,
+				Required:    true,
+			}
+		case mcpservers.ConfigRequiredTypeInitOrReusingOnly:
+			if v, ok := initConfig[key]; ok {
+				if v == "" {
+					return nil, fmt.Errorf("config %s is required", key)
+				}
+				continue
+			}
+			reusingConfig[key] = model.MCPEmbeddingReusingConfig{
+				Name:        value.Name,
+				Description: value.Description,
+				Required:    true,
+			}
+		}
+	}
+	embedConfig.Reusing = reusingConfig
+	return embedConfig, nil
+}
+
+func ToPublicMCP(
+	e mcpservers.McpServer,
+	initConfig map[string]string,
+	enabled bool,
+) (*model.PublicMCP, error) {
+	embedConfig, err := GetEmbedConfig(e.ConfigTemplates, initConfig)
+	if err != nil {
+		return nil, err
+	}
+	pmcp := &model.PublicMCP{
+		ID:          e.ID,
+		Name:        e.Name,
+		LogoURL:     e.LogoURL,
+		Readme:      e.Readme,
+		Tags:        e.Tags,
+		EmbedConfig: embedConfig,
+	}
+	if enabled {
+		pmcp.Status = model.PublicMCPStatusEnabled
+	} else {
+		pmcp.Status = model.PublicMCPStatusDisabled
+	}
+	switch e.Type {
+	case mcpservers.McpTypeEmbed:
+		pmcp.Type = model.PublicMCPTypeEmbed
+	case mcpservers.McpTypeDocs:
+		pmcp.Type = model.PublicMCPTypeDocs
+	}
+	return pmcp, nil
+}
+
+// SaveEmbedMCP godoc
+//
+//	@Summary		Save embed mcp
+//	@Description	Save embed mcp
+//	@Tags			embedmcp
+//	@Accept			json
+//	@Produce		json
+//	@Security		ApiKeyAuth
+//	@Param			body	body		SaveEmbedMCPRequest	true	"Save embed mcp request"
+//	@Success		200		{object}	nil
+//	@Router			/api/embedmcp/ [post]
+func SaveEmbedMCP(c *gin.Context) {
+	var req SaveEmbedMCPRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		middleware.ErrorResponse(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	emcp, ok := mcpservers.GetEmbedMCP(req.ID)
+	if !ok {
+		middleware.ErrorResponse(c, http.StatusNotFound, "embed mcp not found")
+		return
+	}
+
+	pmcp, err := ToPublicMCP(emcp, req.InitConfig, req.Enabled)
+	if err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	if err := model.SavePublicMCP(pmcp); err != nil {
+		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	middleware.SuccessResponse(c, nil)
+}
+
+type testEmbedMcpEndpointProvider struct {
+	key string
+}
+
+func newTestEmbedMcpEndpoint(key string) EndpointProvider {
+	return &testEmbedMcpEndpointProvider{
+		key: key,
+	}
+}
+
+func (m *testEmbedMcpEndpointProvider) NewEndpoint(session string) (newEndpoint string) {
+	endpoint := fmt.Sprintf("/api/test-embedmcp/message?sessionId=%s&key=%s", session, m.key)
+	return endpoint
+}
+
+func (m *testEmbedMcpEndpointProvider) LoadEndpoint(endpoint string) (session string) {
+	parsedURL, err := url.Parse(endpoint)
+	if err != nil {
+		return ""
+	}
+	return parsedURL.Query().Get("sessionId")
+}
+
+// query like:
+// /api/test-embedmcp/aiproxy-openapi/sse?key=adminkey&config[key1]=value1&config[key2]=value2&reusing[key3]=value3
+func getConfigFromQuery(c *gin.Context) (map[string]string, map[string]string) {
+	initConfig := make(map[string]string)
+	reusingConfig := make(map[string]string)
+
+	queryParams := c.Request.URL.Query()
+
+	for paramName, paramValues := range queryParams {
+		if len(paramValues) == 0 {
+			continue
+		}
+
+		paramValue := paramValues[0]
+
+		if strings.HasPrefix(paramName, "config[") && strings.HasSuffix(paramName, "]") {
+			key := paramName[7 : len(paramName)-1]
+			if key != "" {
+				initConfig[key] = paramValue
+			}
+		}
+
+		if strings.HasPrefix(paramName, "reusing[") && strings.HasSuffix(paramName, "]") {
+			key := paramName[8 : len(paramName)-1]
+			if key != "" {
+				reusingConfig[key] = paramValue
+			}
+		}
+	}
+
+	return initConfig, reusingConfig
+}
+
+// TestEmbedMCPSseServer godoc
+//
+//	@Summary		Test Embed MCP SSE Server
+//	@Description	Test Embed MCP SSE Server
+//	@Tags			embedmcp
+//	@Security		ApiKeyAuth
+//	@Param			id				path		string	true	"MCP ID"
+//	@Param			config[key]		query		string	false	"Initial configuration parameters (e.g. config[host]=http://localhost:3000)"
+//	@Param			reusing[key]	query		string	false	"Reusing configuration parameters (e.g. reusing[authorization]=apikey)"
+//	@Success		200				{object}	nil
+//	@Failure		400				{object}	nil
+//	@Router			/api/test-embedmcp/{id}/sse [get]
+func TestEmbedMCPSseServer(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		http.Error(c.Writer, "mcp id is required", http.StatusBadRequest)
+		return
+	}
+
+	initConfig, reusingConfig := getConfigFromQuery(c)
+	emcp, err := mcpservers.GetMCPServer(id, initConfig, reusingConfig)
+	if err != nil {
+		http.Error(c.Writer, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	handleTestEmbedMCPServer(c, emcp)
+}
+
+const (
+	testEmbedMcpType = "test-embedmcp"
+)
+
+func handleTestEmbedMCPServer(c *gin.Context, s mcpservers.Server) {
+	token := middleware.GetToken(c)
+
+	// Store the session
+	store := getStore()
+	newSession := store.New()
+
+	newEndpoint := newTestEmbedMcpEndpoint(token.Key).NewEndpoint(newSession)
+	server := mcpproxy.NewSSEServer(
+		s,
+		mcpproxy.WithMessageEndpoint(newEndpoint),
+	)
+
+	store.Set(newSession, testEmbedMcpType)
+	defer func() {
+		store.Delete(newSession)
+	}()
+
+	ctx, cancel := context.WithCancel(c.Request.Context())
+	defer cancel()
+
+	// Start message processing goroutine
+	go processMCPSSEMpscMessages(ctx, newSession, server)
+
+	// Handle SSE connection
+	server.ServeHTTP(c.Writer, c.Request)
+}
+
+// TestEmbedMCPMessage godoc
+//
+//	@Summary		Test Embed MCP Message
+//	@Description	Send a message to the test embed MCP server
+//	@Tags			embedmcp
+//	@Security		ApiKeyAuth
+//	@Param			sessionId	query	string	true	"Session ID"
+//	@Accept			json
+//	@Produce		json
+//	@Success		200	{object}	nil
+//	@Failure		400	{object}	nil
+//	@Router			/api/test-embedmcp/message [post]
+func TestEmbedMCPMessage(c *gin.Context) {
+	sessionID, _ := c.GetQuery("sessionId")
+	if sessionID == "" {
+		http.Error(c.Writer, "missing sessionId", http.StatusBadRequest)
+		return
+	}
+
+	sendMCPSSEMessage(c, testEmbedMcpType, sessionID)
+}
+
+// TestEmbedMCPStreamable godoc
+//
+//	@Summary		Test Embed MCP Streamable Server
+//	@Description	Test Embed MCP Streamable Server with various HTTP methods
+//	@Tags			embedmcp
+//	@Security		ApiKeyAuth
+//	@Param			id				path	string	true	"MCP ID"
+//	@Param			config[key]		query	string	false	"Initial configuration parameters (e.g. config[host]=http://localhost:3000)"
+//	@Param			reusing[key]	query	string	false	"Reusing configuration parameters (e.g., reusing[authorization]=apikey)"
+//	@Accept			json
+//	@Produce		json
+//	@Success		200	{object}	nil
+//	@Failure		400	{object}	nil
+//	@Router			/api/test-embedmcp/{id} [get]
+//	@Router			/api/test-embedmcp/{id} [post]
+//	@Router			/api/test-embedmcp/{id} [delete]
+func TestEmbedMCPStreamable(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		c.JSON(http.StatusBadRequest, mcpservers.CreateMCPErrorResponse(
+			mcp.NewRequestId(nil),
+			mcp.INVALID_REQUEST,
+			"mcp id is required",
+		))
+		return
+	}
+
+	initConfig, reusingConfig := getConfigFromQuery(c)
+	server, err := mcpservers.GetMCPServer(id, initConfig, reusingConfig)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, mcpservers.CreateMCPErrorResponse(
+			mcp.NewRequestId(nil),
+			mcp.INVALID_REQUEST,
+			err.Error(),
+		))
+		return
+	}
+	handleStreamableMCPServer(c, server)
+}
+
+```
+
+File: core/model/publicmcp.go
+```go
+package model
+
+import (
+	"errors"
+	"net/url"
+	"regexp"
+	"time"
+
+	"github.com/bytedance/sonic"
+	"github.com/labring/aiproxy/core/common"
+	log "github.com/sirupsen/logrus"
+	"gorm.io/gorm"
+)
+
+type PublicMCPStatus int
+
+const (
+	PublicMCPStatusEnabled PublicMCPStatus = iota + 1
+	PublicMCPStatusDisabled
+)
+
+const (
+	ErrPublicMCPNotFound       = "public mcp"
+	ErrMCPReusingParamNotFound = "mcp reusing param"
+)
+
+type PublicMCPType string
+
+const (
+	PublicMCPTypeProxySSE        PublicMCPType = "mcp_proxy_sse"
+	PublicMCPTypeProxyStreamable PublicMCPType = "mcp_proxy_streamable"
+	PublicMCPTypeDocs            PublicMCPType = "mcp_docs" // read only
+	PublicMCPTypeOpenAPI         PublicMCPType = "mcp_openapi"
+	PublicMCPTypeEmbed           PublicMCPType = "mcp_embed"
+)
+
+type ParamType string
+
+const (
+	ParamTypeHeader ParamType = "header"
+	ParamTypeQuery  ParamType = "query"
+)
+
+type ReusingParam struct {
+	Name        string    `json:"name"`
+	Description string    `json:"description"`
+	Type        ParamType `json:"type"`
+	Required    bool      `json:"required"`
+}
+
+type MCPPrice struct {
+	DefaultToolsCallPrice float64            `json:"default_tools_call_price"`
+	ToolsCallPrices       map[string]float64 `json:"tools_call_prices"        gorm:"serializer:fastjson;type:text"`
+}
+
+type PublicMCPProxyConfig struct {
+	URL           string                  `json:"url"`
+	Querys        map[string]string       `json:"querys"`
+	Headers       map[string]string       `json:"headers"`
+	ReusingParams map[string]ReusingParam `json:"reusing_params"`
+}
+
+type PublicMCPReusingParam struct {
+	MCPID         string            `gorm:"primaryKey"                    json:"mcp_id"`
+	GroupID       string            `gorm:"primaryKey"                    json:"group_id"`
+	CreatedAt     time.Time         `gorm:"index"                         json:"created_at"`
+	UpdateAt      time.Time         `gorm:"index"                         json:"update_at"`
+	Group         *Group            `gorm:"foreignKey:GroupID"            json:"-"`
+	ReusingParams map[string]string `gorm:"serializer:fastjson;type:text" json:"reusing_params"`
+}
+
+func (p *PublicMCPReusingParam) BeforeCreate(_ *gorm.DB) (err error) {
+	if p.MCPID == "" {
+		return errors.New("mcp id is empty")
+	}
+	if p.GroupID == "" {
+		return errors.New("group is empty")
+	}
+	return
+}
+
+func (p *PublicMCPReusingParam) MarshalJSON() ([]byte, error) {
+	type Alias PublicMCPReusingParam
+	a := &struct {
+		*Alias
+		CreatedAt int64 `json:"created_at"`
+		UpdateAt  int64 `json:"update_at"`
+	}{
+		Alias:     (*Alias)(p),
+		CreatedAt: p.CreatedAt.UnixMilli(),
+		UpdateAt:  p.UpdateAt.UnixMilli(),
+	}
+	return sonic.Marshal(a)
+}
+
+type MCPOpenAPIConfig struct {
+	OpenAPISpec    string `json:"openapi_spec"`
+	OpenAPIContent string `json:"openapi_content,omitempty"`
+	V2             bool   `json:"v2"`
+	ServerAddr     string `json:"server_addr,omitempty"`
+	Authorization  string `json:"authorization,omitempty"`
+}
+
+type MCPEmbeddingReusingConfig struct {
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	Required    bool   `json:"required"`
+}
+
+type MCPEmbeddingConfig struct {
+	Init    map[string]string                    `json:"init"`
+	Reusing map[string]MCPEmbeddingReusingConfig `json:"reusing"`
+}
+
+var validateMCPIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+)
+
+func validateMCPID(id string) error {
+	if id == "" {
+		return errors.New("mcp id is empty")
+	}
+	if !validateMCPIDRegex.MatchString(id) {
+		return errors.New("mcp id is invalid")
+	}
+	return nil
+}
+
+type PublicMCP struct {
+	ID                     string                  `gorm:"primaryKey"                    json:"id"`
+	Status                 PublicMCPStatus         `gorm:"index;default:1"               json:"status"`
+	CreatedAt              time.Time               `gorm:"index,autoCreateTime"          json:"created_at"`
+	UpdateAt               time.Time               `gorm:"index,autoUpdateTime"          json:"update_at"`
+	PublicMCPReusingParams []PublicMCPReusingParam `gorm:"foreignKey:MCPID"              json:"-"`
+	Name                   string                  `                                     json:"name"`
+	Type                   PublicMCPType           `gorm:"index"                         json:"type"`
+	RepoURL                string                  `                                     json:"repo_url"`
+	ReadmeURL              string                  `                                     json:"readme_url"`
+	Readme                 string                  `gorm:"type:text"                     json:"readme"`
+	Tags                   []string                `gorm:"serializer:fastjson;type:text" json:"tags,omitempty"`
+	LogoURL                string                  `                                     json:"logo_url"`
+	Price                  MCPPrice                `gorm:"embedded"                      json:"price"`
+	ProxyConfig            *PublicMCPProxyConfig   `gorm:"serializer:fastjson;type:text" json:"proxy_config,omitempty"`
+	OpenAPIConfig          *MCPOpenAPIConfig       `gorm:"serializer:fastjson;type:text" json:"openapi_config,omitempty"`
+	EmbedConfig            *MCPEmbeddingConfig     `gorm:"serializer:fastjson;type:text" json:"embed_config,omitempty"`
+}
+
+func (p *PublicMCP) BeforeSave(_ *gorm.DB) error {
+	if err := validateMCPID(p.ID); err != nil {
+		return err
+	}
+
+	if p.Status == 0 {
+		p.Status = PublicMCPStatusEnabled
+	}
+
+	if p.OpenAPIConfig != nil {
+		config := p.OpenAPIConfig
+		if config.OpenAPISpec != "" {
+			return validateHTTPURL(config.OpenAPISpec)
+		}
+		if config.OpenAPIContent != "" {
+			return nil
+		}
+		return errors.New("openapi spec and content is empty")
+	}
+
+	if p.ProxyConfig != nil {
+		config := p.ProxyConfig
+		return validateHTTPURL(config.URL)
+	}
+	return nil
+}
+
+func validateHTTPURL(str string) error {
+	if str == "" {
+		return errors.New("url is empty")
+	}
+	u, err := url.Parse(str)
+	if err != nil {
+		return err
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return errors.New("url scheme not support")
+	}
+	return nil
+}
+
+func (p *PublicMCP) BeforeDelete(tx *gorm.DB) (err error) {
+	return tx.Model(&PublicMCPReusingParam{}).
+		Where("mcp_id = ?", p.ID).
+		Delete(&PublicMCPReusingParam{}).
+		Error
+}
+
+// CreatePublicMCP creates a new MCP
+func CreatePublicMCP(mcp *PublicMCP) error {
+	err := DB.Create(mcp).Error
+	if err != nil && errors.Is(err, gorm.ErrDuplicatedKey) {
+		return errors.New("mcp server already exist")
+	}
+	return err
+}
+
+func SavePublicMCP(mcp *PublicMCP) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCP(mcp.ID); err != nil {
+				log.Error("cache delete public mcp error: " + err.Error())
+			}
+		}
+	}()
+
+	return DB.Save(mcp).Error
+}
+
+// UpdatePublicMCP updates an existing MCP
+func UpdatePublicMCP(mcp *PublicMCP) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCP(mcp.ID); err != nil {
+				log.Error("cache delete public mcp error: " + err.Error())
+			}
+		}
+	}()
+
+	selects := []string{
+		"repo_url",
+		"readme",
+		"readme_url",
+		"tags",
+		"author",
+		"logo_url",
+		"proxy_config",
+		"openapi_config",
+		"embed_config",
+	}
+	if mcp.Status != 0 {
+		selects = append(selects, "status")
+	}
+	if mcp.Name != "" {
+		selects = append(selects, "name")
+	}
+	if mcp.Type != "" {
+		selects = append(selects, "type")
+	}
+	if mcp.Price.DefaultToolsCallPrice != 0 ||
+		len(mcp.Price.ToolsCallPrices) != 0 {
+		selects = append(selects, "price")
+	}
+	result := DB.
+		Select(selects).
+		Where("id = ?", mcp.ID).
+		Updates(mcp)
+	return HandleUpdateResult(result, ErrPublicMCPNotFound)
+}
+
+func UpdatePublicMCPStatus(id string, status PublicMCPStatus) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheUpdatePublicMCPStatus(id, status); err != nil {
+				log.Error("cache update public mcp status error: " + err.Error())
+			}
+		}
+	}()
+
+	result := DB.Model(&PublicMCP{}).Where("id = ?", id).Update("status", status)
+	return HandleUpdateResult(result, ErrPublicMCPNotFound)
+}
+
+// DeletePublicMCP deletes an MCP by ID
+func DeletePublicMCP(id string) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCP(id); err != nil {
+				log.Error("cache delete public mcp error: " + err.Error())
+			}
+		}
+	}()
+
+	if id == "" {
+		return errors.New("MCP id is empty")
+	}
+	result := DB.Delete(&PublicMCP{ID: id})
+	return HandleUpdateResult(result, ErrPublicMCPNotFound)
+}
+
+// GetPublicMCPByID retrieves an MCP by ID
+func GetPublicMCPByID(id string) (PublicMCP, error) {
+	var mcp PublicMCP
+	if id == "" {
+		return mcp, errors.New("MCP id is empty")
+	}
+	err := DB.Where("id = ?", id).First(&mcp).Error
+	return mcp, HandleNotFound(err, ErrPublicMCPNotFound)
+}
+
+// GetPublicMCPs retrieves MCPs with pagination and filtering
+func GetPublicMCPs(
+	page, perPage int,
+	mcpType PublicMCPType,
+	keyword string,
+	status PublicMCPStatus,
+) (mcps []PublicMCP, total int64, err error) {
+	tx := DB.Model(&PublicMCP{})
+
+	if mcpType != "" {
+		tx = tx.Where("type = ?", mcpType)
+	}
+
+	if keyword != "" {
+		keyword = "%" + keyword + "%"
+		if common.UsingPostgreSQL {
+			tx = tx.Where(
+				"name ILIKE ? OR author ILIKE ? OR tags ILIKE ? OR id ILIKE ?",
+				keyword,
+				keyword,
+				keyword,
+				keyword,
+			)
+		} else {
+			tx = tx.Where("name LIKE ? OR author LIKE ? OR tags LIKE ? OR id LIKE ?", keyword, keyword, keyword, keyword)
+		}
+	}
+
+	if status != 0 {
+		tx = tx.Where("status = ?", status)
+	}
+
+	err = tx.Count(&total).Error
+	if err != nil {
+		return nil, 0, err
+	}
+
+	if total <= 0 {
+		return nil, 0, nil
+	}
+
+	limit, offset := toLimitOffset(page, perPage)
+	err = tx.
+		Limit(limit).
+		Offset(offset).
+		Find(&mcps).
+		Error
+
+	return mcps, total, err
+}
+
+func GetAllPublicMCPs(status PublicMCPStatus) ([]PublicMCP, error) {
+	var mcps []PublicMCP
+	tx := DB.Model(&PublicMCP{})
+	if status != 0 {
+		tx = tx.Where("status = ?", status)
+	}
+	err := tx.Find(&mcps).Error
+	return mcps, err
+}
+
+func GetPublicMCPsEnabled(ids []string) ([]string, error) {
+	var mcpIDs []string
+	err := DB.Model(&PublicMCP{}).
+		Select("id").
+		Where("id IN (?) AND status = ?", ids, PublicMCPStatusEnabled).
+		Pluck("id", &mcpIDs).
+		Error
+	if err != nil {
+		return nil, err
+	}
+	return mcpIDs, nil
+}
+
+func SavePublicMCPReusingParam(param *PublicMCPReusingParam) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCPReusingParam(param.MCPID, param.GroupID); err != nil {
+				log.Error("cache delete public mcp reusing param error: " + err.Error())
+			}
+		}
+	}()
+
+	return DB.Save(param).Error
+}
+
+// UpdatePublicMCPReusingParam updates an existing GroupMCPReusingParam
+func UpdatePublicMCPReusingParam(param *PublicMCPReusingParam) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCPReusingParam(param.MCPID, param.GroupID); err != nil {
+				log.Error("cache delete public mcp reusing param error: " + err.Error())
+			}
+		}
+	}()
+
+	result := DB.
+		Select([]string{
+			"reusing_params",
+		}).
+		Where("mcp_id = ? AND group_id = ?", param.MCPID, param.GroupID).
+		Updates(param)
+	return HandleUpdateResult(result, ErrMCPReusingParamNotFound)
+}
+
+// DeletePublicMCPReusingParam deletes a GroupMCPReusingParam
+func DeletePublicMCPReusingParam(mcpID, groupID string) (err error) {
+	defer func() {
+		if err == nil {
+			if err := CacheDeletePublicMCPReusingParam(mcpID, groupID); err != nil {
+				log.Error("cache delete public mcp reusing param error: " + err.Error())
+			}
+		}
+	}()
+
+	if mcpID == "" || groupID == "" {
+		return errors.New("MCP ID or Group ID is empty")
+	}
+	result := DB.
+		Where("mcp_id = ? AND group_id = ?", mcpID, groupID).
+		Delete(&PublicMCPReusingParam{})
+	return HandleUpdateResult(result, ErrMCPReusingParamNotFound)
+}
+
+// GetPublicMCPReusingParam retrieves a GroupMCPReusingParam by MCP ID and Group ID
+func GetPublicMCPReusingParam(mcpID, groupID string) (*PublicMCPReusingParam, error) {
+	if mcpID == "" || groupID == "" {
+		return nil, errors.New("MCP ID or Group ID is empty")
+	}
+	var param PublicMCPReusingParam
+	err := DB.Where("mcp_id = ? AND group_id = ?", mcpID, groupID).First(&param).Error
+	return &param, HandleNotFound(err, ErrMCPReusingParamNotFound)
+}
+
+```
+
+File: mcp-servers/mcp.go
+```go
+package mcpservers
+
+import (
+	"errors"
+	"fmt"
+)
+
+type ConfigValueValidator func(value string) error
+
+type ConfigRequiredType int
+
+const (
+	ConfigRequiredTypeInitOptional ConfigRequiredType = iota
+	ConfigRequiredTypeReusingOptional
+	ConfigRequiredTypeInitOnly
+	ConfigRequiredTypeReusingOnly
+	ConfigRequiredTypeInitOrReusingOnly
+)
+
+func (c ConfigRequiredType) Validate(config, reusingConfig string) error {
+	switch c {
+	case ConfigRequiredTypeInitOnly:
+		if config == "" {
+			return errors.New("config is required")
+		}
+	case ConfigRequiredTypeReusingOnly:
+		if reusingConfig == "" {
+			return errors.New("reusing config is required")
+		}
+	case ConfigRequiredTypeInitOrReusingOnly:
+		if config == "" && reusingConfig == "" {
+			return errors.New("config or reusing config is required")
+		}
+		if config != "" && reusingConfig != "" {
+			return errors.New(
+				"config and reusing config are both provided, but only one is allowed",
+			)
+		}
+	}
+	return nil
+}
+
+type ConfigTemplate struct {
+	Name        string               `json:"name"`
+	Required    ConfigRequiredType   `json:"required"`
+	Example     string               `json:"example,omitempty"`
+	Description string               `json:"description,omitempty"`
+	Validator   ConfigValueValidator `json:"-"`
+}
+
+type ConfigTemplates = map[string]ConfigTemplate
+
+func ValidateConfigTemplatesConfig(
+	ct ConfigTemplates,
+	config, reusingConfig map[string]string,
+) error {
+	if len(ct) == 0 {
+		return nil
+	}
+
+	for key, template := range ct {
+		c := config[key]
+		rc := reusingConfig[key]
+		if err := template.Required.Validate(c, rc); err != nil {
+			return fmt.Errorf("config required %s is invalid: %w", key, err)
+		}
+		if template.Validator != nil {
+			if c != "" {
+				if err := template.Validator(c); err != nil {
+					return fmt.Errorf("config %s is invalid: %w", key, err)
+				}
+			} else if rc != "" {
+				if err := template.Validator(rc); err != nil {
+					return fmt.Errorf("reusing config %s is invalid: %w", key, err)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func CheckConfigTemplatesValidate(ct ConfigTemplates) error {
+	for key, value := range ct {
+		if value.Name == "" {
+			return fmt.Errorf("config %s name is required", key)
+		}
+		if value.Description == "" {
+			return fmt.Errorf("config %s description is required", key)
+		}
+		if value.Example == "" || value.Validator == nil {
+			continue
+		}
+		if err := value.Validator(value.Example); err != nil {
+			return fmt.Errorf("config %s example is invalid: %w", key, err)
+		}
+	}
+	return nil
+}
+
+type NewServerFunc func(config, reusingConfig map[string]string) (Server, error)
+
+type McpType string
+
+const (
+	McpTypeEmbed McpType = "embed"
+	McpTypeDocs  McpType = "docs"
+)
+
+type McpServer struct {
+	ID              string
+	Name            string
+	Type            McpType
+	Readme          string
+	LogoURL         string
+	Tags            []string
+	ConfigTemplates ConfigTemplates
+	newServer       NewServerFunc
+}
+
+type McpConfig func(*McpServer)
+
+func WithReadme(readme string) McpConfig {
+	return func(e *McpServer) {
+		e.Readme = readme
+	}
+}
+
+func WithType(t McpType) McpConfig {
+	return func(e *McpServer) {
+		e.Type = t
+	}
+}
+
+func WithLogoURL(logoURL string) McpConfig {
+	return func(e *McpServer) {
+		e.LogoURL = logoURL
+	}
+}
+
+func WithTags(tags []string) McpConfig {
+	return func(e *McpServer) {
+		e.Tags = tags
+	}
+}
+
+func WithConfigTemplates(configTemplates ConfigTemplates) McpConfig {
+	return func(e *McpServer) {
+		e.ConfigTemplates = configTemplates
+	}
+}
+
+func WithNewServerFunc(newServer NewServerFunc) McpConfig {
+	return func(e *McpServer) {
+		e.newServer = newServer
+	}
+}
+
+func NewMcp(id, name string, mcpType McpType, opts ...McpConfig) McpServer {
+	e := McpServer{
+		ID:   id,
+		Name: name,
+		Type: mcpType,
+	}
+	for _, opt := range opts {
+		opt(&e)
+	}
+	return e
+}
+
+func (e *McpServer) NewServer(config, reusingConfig map[string]string) (Server, error) {
+	if err := ValidateConfigTemplatesConfig(e.ConfigTemplates, config, reusingConfig); err != nil {
+		return nil, fmt.Errorf("mcp %s config is invalid: %w", e.ID, err)
+	}
+	return e.newServer(config, reusingConfig)
+}
+
+```
+
+File: mcp-servers/register.go
+```go
+package mcpservers
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+)
+
+type mcpServerCacheItem struct {
+	MCPServer         Server
+	LastUsedTimestamp atomic.Int64
+}
+
+var (
+	servers             = make(map[string]McpServer)
+	mcpServerCache      = make(map[string]*mcpServerCacheItem)
+	mcpServerCacheLock  = sync.RWMutex{}
+	cacheExpirationTime = 3 * time.Minute
+)
+
+func startCacheCleaner(interval time.Duration) {
+	go func() {
+		ticker := time.NewTicker(interval)
+		defer ticker.Stop()
+
+		for range ticker.C {
+			cleanupExpiredCache()
+		}
+	}()
+}
+
+func cleanupExpiredCache() {
+	now := time.Now().Unix()
+	expiredTime := now - int64(cacheExpirationTime.Seconds())
+
+	mcpServerCacheLock.Lock()
+	defer mcpServerCacheLock.Unlock()
+
+	for key, item := range mcpServerCache {
+		if item.LastUsedTimestamp.Load() < expiredTime {
+			delete(mcpServerCache, key)
+		}
+	}
+}
+
+func init() {
+	startCacheCleaner(time.Minute)
+}
+
+func Register(mcp McpServer) {
+	if mcp.ID == "" {
+		panic("mcp id is required")
+	}
+	if mcp.Name == "" {
+		panic("mcp name is required")
+	}
+	switch mcp.Type {
+	case McpTypeEmbed:
+		if mcp.newServer == nil {
+			panic(fmt.Sprintf("mcp %s new server is required", mcp.ID))
+		}
+	case McpTypeDocs:
+		if mcp.Readme == "" {
+			panic(fmt.Sprintf("mcp %s readme is required", mcp.ID))
+		}
+	default:
+		panic(fmt.Sprintf("mcp %s type is invalid", mcp.ID))
+	}
+
+	if mcp.ConfigTemplates != nil {
+		if err := CheckConfigTemplatesValidate(mcp.ConfigTemplates); err != nil {
+			panic(fmt.Sprintf("mcp %s config templates example is invalid: %v", mcp.ID, err))
+		}
+	}
+	if _, ok := servers[mcp.ID]; ok {
+		panic(fmt.Sprintf("mcp %s already registered", mcp.ID))
+	}
+	servers[mcp.ID] = mcp
+}
+
+func GetMCPServer(id string, config, reusingConfig map[string]string) (Server, error) {
+	embedServer, ok := servers[id]
+	if !ok {
+		return nil, fmt.Errorf("mcp %s not found", id)
+	}
+	if len(embedServer.ConfigTemplates) == 0 {
+		return loadCacheServer(embedServer, nil)
+	}
+
+	if err := ValidateConfigTemplatesConfig(embedServer.ConfigTemplates, config, reusingConfig); err != nil {
+		return nil, fmt.Errorf("mcp %s config is invalid: %w", id, err)
+	}
+
+	for _, template := range embedServer.ConfigTemplates {
+		switch template.Required {
+		case ConfigRequiredTypeReusingOptional,
+			ConfigRequiredTypeReusingOnly,
+			ConfigRequiredTypeInitOrReusingOnly:
+			return embedServer.NewServer(config, reusingConfig)
+		}
+	}
+
+	return loadCacheServer(embedServer, config)
+}
+
+func buildNoReusingConfigCacheKey(config map[string]string) string {
+	keys := make([]string, 0, len(config))
+	for key, value := range config {
+		keys = append(keys, fmt.Sprintf("%s:%s", key, value))
+	}
+	sort.Strings(keys)
+	return strings.Join(keys, ":")
+}
+
+func loadCacheServer(embedServer McpServer, config map[string]string) (Server, error) {
+	cacheKey := embedServer.ID
+	if len(config) > 0 {
+		cacheKey = fmt.Sprintf("%s:%s", embedServer.ID, buildNoReusingConfigCacheKey(config))
+	}
+	mcpServerCacheLock.RLock()
+	server, ok := mcpServerCache[cacheKey]
+	mcpServerCacheLock.RUnlock()
+	if ok {
+		server.LastUsedTimestamp.Store(time.Now().Unix())
+		return server.MCPServer, nil
+	}
+
+	mcpServerCacheLock.Lock()
+	defer mcpServerCacheLock.Unlock()
+	server, ok = mcpServerCache[cacheKey]
+	if ok {
+		server.LastUsedTimestamp.Store(time.Now().Unix())
+		return server.MCPServer, nil
+	}
+
+	mcpServer, err := embedServer.NewServer(config, nil)
+	if err != nil {
+		return nil, fmt.Errorf("mcp %s new server is invalid: %w", embedServer.ID, err)
+	}
+	mcpServerCacheItem := &mcpServerCacheItem{
+		MCPServer:         mcpServer,
+		LastUsedTimestamp: atomic.Int64{},
+	}
+	mcpServerCacheItem.LastUsedTimestamp.Store(time.Now().Unix())
+	mcpServerCache[cacheKey] = mcpServerCacheItem
+	return mcpServer, nil
+}
+
+func Servers() map[string]McpServer {
+	return servers
+}
+
+func GetEmbedMCP(id string) (McpServer, bool) {
+	mcp, ok := servers[id]
+	return mcp, ok
+}
+
+```
+
+File: mcp-servers/aiproxy-openapi/openapi.go
+```go
+package aiproxyopenapi
+
+import (
+	"fmt"
+	"net/url"
+	"sync"
+
+	"github.com/labring/aiproxy/core/docs"
+	mcpservers "github.com/labring/aiproxy/mcp-servers"
+	"github.com/labring/aiproxy/openapi-mcp/convert"
+)
+
+var configTemplates = map[string]mcpservers.ConfigTemplate{
+	"host": {
+		Name:        "Host",
+		Required:    mcpservers.ConfigRequiredTypeInitOnly,
+		Example:     "http://localhost:3000",
+		Description: "The host of the OpenAPI server",
+		Validator: func(value string) error {
+			u, err := url.Parse(value)
+			if err != nil {
+				return err
+			}
+			if u.Scheme != "http" && u.Scheme != "https" {
+				return fmt.Errorf("invalid scheme: %s", u.Scheme)
+			}
+			return nil
+		},
+	},
+
+	"authorization": {
+		Name:        "Authorization",
+		Required:    mcpservers.ConfigRequiredTypeReusingOptional,
+		Example:     "aiproxy-admin-key",
+		Description: "The admin key of the OpenAPI server",
+	},
+}
+
+var (
+	parser    *convert.Parser
+	parseOnce sync.Once
+)
+
+func getParser() *convert.Parser {
+	parseOnce.Do(func() {
+		parser = convert.NewParser()
+		err := parser.Parse([]byte(docs.SwaggerInfo.ReadDoc()))
+		if err != nil {
+			panic(err)
+		}
+	})
+	return parser
+}
+
+func NewServer(config, reusingConfig map[string]string) (mcpservers.Server, error) {
+	converter := convert.NewConverter(getParser(), convert.Options{
+		OpenAPIFrom:   config["host"],
+		Authorization: reusingConfig["authorization"],
+	})
+	return converter.Convert()
+}
+
+```
+
+File: mcp-servers/amap/main.go
+```go
+package amap
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+
+	mcpservers "github.com/labring/aiproxy/mcp-servers"
+	"github.com/mark3labs/mcp-go/client/transport"
+)
+
+var configTemplates = map[string]mcpservers.ConfigTemplate{
+	"key": {
+		Name:        "Key",
+		Required:    mcpservers.ConfigRequiredTypeInitOnly,
+		Example:     "1234567890",
+		Description: "The key of the AMap MCP server: https://console.amap.com/dev/key/app",
+	},
+
+	"url": {
+		Name:        "URL",
+		Required:    mcpservers.ConfigRequiredTypeInitOptional,
+		Example:     "https://mcp.amap.com/sse",
+		Description: "The URL of the AMap MCP server",
+	},
+}
+
+func NewServer(config, _ map[string]string) (mcpservers.Server, error) {
+	key := config["key"]
+	if key == "" {
+		return nil, errors.New("key is required")
+	}
+	u := config["url"]
+	if u == "" {
+		u = "https://mcp.amap.com/sse"
+	}
+
+	parsedURL, err := url.Parse(u)
+	if err != nil {
+		return nil, fmt.Errorf("invalid url: %w", err)
+	}
+	query := parsedURL.Query()
+	query.Set("key", key)
+	parsedURL.RawQuery = query.Encode()
+
+	client, err := transport.NewSSE(parsedURL.String())
+	if err != nil {
+		return nil, fmt.Errorf("failed to create sse client: %w", err)
+	}
+
+	err = client.Start(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("failed to start sse client: %w", err)
+	}
+
+	return mcpservers.WrapMCPClient2ServerWithCleanup(client), nil
+}
+
+```
+
+File: mcp-servers/amap/init.go
+```go
+package amap
+
+import mcpservers "github.com/labring/aiproxy/mcp-servers"
+
+// need import in mcpregister/init.go
+func init() {
+	mcpservers.Register(
+		mcpservers.NewMcp(
+			"amap",
+			"AMAP",
+			mcpservers.McpTypeEmbed,
+			mcpservers.WithNewServerFunc(NewServer),
+			mcpservers.WithConfigTemplates(configTemplates),
+			mcpservers.WithTags([]string{"map"}),
+			mcpservers.WithReadme(
+				`# AMAP MCP Server
+
+https://lbs.amap.com/api/mcp-server/gettingstarted
+`),
+		),
+	)
+}
+
+```
+
+File: mcp-servers/aiproxy-openapi/init.go
+```go
+package aiproxyopenapi
+
+import mcpservers "github.com/labring/aiproxy/mcp-servers"
+
+// need import in mcpregister/init.go
+func init() {
+	mcpservers.Register(
+		mcpservers.NewMcp(
+			"aiproxy-openapi",
+			"AI Proxy OpenAPI",
+			mcpservers.McpTypeEmbed,
+			mcpservers.WithNewServerFunc(NewServer),
+			mcpservers.WithConfigTemplates(configTemplates),
+		),
+	)
+}
+
+```
+
+File: mcp-servers/mcpregister/init.go
+```go
+package mcpregister
+
+import (
+	// register embed mcp
+	_ "github.com/labring/aiproxy/mcp-servers/aiproxy-openapi"
+	_ "github.com/labring/aiproxy/mcp-servers/alipay"
+	_ "github.com/labring/aiproxy/mcp-servers/amap"
+	_ "github.com/labring/aiproxy/mcp-servers/web-search"
+)
+
+```
+
+
+这是后端的代码,帮我加一个页面,页面有一个tab可以切换三个子页面。第一个页面展示所有的mcp列表、并展示sse和sstreamhttp的地址。第二个页面展示所有内置的mcp服务器、并提供配置参数、保存启用等功能。第三个页面展示mcp服务器配置功能、可以修改参数、添加mcp后端等操作

+ 9 - 4
web/package.json

@@ -16,12 +16,13 @@
     "@radix-ui/react-collapsible": "^1.1.8",
     "@radix-ui/react-dialog": "^1.1.11",
     "@radix-ui/react-dropdown-menu": "^2.1.12",
-    "@radix-ui/react-label": "^2.1.4",
+    "@radix-ui/react-label": "^2.1.7",
     "@radix-ui/react-popover": "^1.1.14",
-    "@radix-ui/react-select": "^2.2.2",
+    "@radix-ui/react-select": "^2.2.5",
     "@radix-ui/react-separator": "^1.1.4",
     "@radix-ui/react-slot": "^1.2.0",
-    "@radix-ui/react-switch": "^1.2.2",
+    "@radix-ui/react-switch": "^1.2.5",
+    "@radix-ui/react-tabs": "^1.1.12",
     "@radix-ui/react-tooltip": "^1.2.4",
     "@tailwindcss/vite": "^4.1.4",
     "@tanstack/react-query": "^5.74.4",
@@ -45,8 +46,10 @@
     "react-hook-form": "^7.56.1",
     "react-i18next": "^15.5.1",
     "react-json-view": "^1.21.3",
+    "react-markdown": "^10.1.0",
     "react-router": "^7.5.1",
     "react-syntax-highlighter": "^15.6.1",
+    "remark-gfm": "^4.0.1",
     "sonner": "^2.0.3",
     "tailwind-merge": "^3.2.0",
     "tailwindcss": "^4.1.4",
@@ -56,6 +59,7 @@
   },
   "devDependencies": {
     "@eslint/js": "^9.22.0",
+    "@tailwindcss/typography": "^0.5.16",
     "@types/node": "^22.14.1",
     "@types/react": "^19.0.10",
     "@types/react-dom": "^19.0.4",
@@ -69,5 +73,6 @@
     "typescript": "~5.7.2",
     "typescript-eslint": "^8.26.1",
     "vite": "^6.3.1"
-  }
+  },
+  "packageManager": "[email protected]+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912"
 }

Разница между файлами не показана из-за своего большого размера
+ 253 - 286
web/pnpm-lock.yaml


+ 165 - 2
web/public/locales/en/translation.json

@@ -4,6 +4,7 @@
     "key": "API Keys",
     "channel": "Channel",
     "model": "Model",
+    "mcp": "MCP",
     "log": "Logs",
     "doc": "Document",
     "github": "GitHub",
@@ -20,7 +21,9 @@
     },
     "global": "Global",
     "loading": "Loading...",
-    "selectDateRange": "Select date range"
+    "selectDateRange": "Select date range",
+    "success": "Success",
+    "optional": "(Optional)"
   },
   "error": {
     "loading": "Failed to load data",
@@ -154,6 +157,11 @@
     "modelType": "Model Type",
     "owner": "Owner",
     "rpm": "RPM",
+    "tpm": "TPM",
+    "retryTimes": "Retry Times",
+    "timeout": "Timeout",
+    "maxErrorRate": "Max Error Rate",
+    "forceSaveDetail": "Force Save Detail",
     "add": "Add Model",
     "edit": "Edit",
     "delete": "Delete",
@@ -167,6 +175,10 @@
     "cachePlugin": "Cache",
     "webSearchPlugin": "Web Search",
     "thinkSplitPlugin": "Think Split",
+    "accessibleSets": "Accessible Sets",
+    "loading": "Loading...",
+    "noChannel": "No available channels",
+    "availableChannels": "Available Channels",
     "dialog": {
       "createTitle": "Create Model",
       "updateTitle": "Update Model",
@@ -176,6 +188,17 @@
       "modelNamePlaceholder": "Enter model name",
       "modelType": "Model Type",
       "selectType": "Select model type",
+      "rpm": "RPM (Requests Per Minute)",
+      "rpmPlaceholder": "Enter RPM limit",
+      "tpm": "TPM (Tokens Per Minute)",
+      "tpmPlaceholder": "Enter TPM limit",
+      "retryTimes": "Retry Times",
+      "retryTimesPlaceholder": "Number of retry attempts",
+      "timeout": "Timeout (seconds)",
+      "timeoutPlaceholder": "Request timeout in seconds",
+      "maxErrorRate": "Max Error Rate (0-1)",
+      "maxErrorRatePlaceholder": "Maximum error rate (0-1)",
+      "forceSaveDetail": "Force Save Detail",
       "create": "Create",
       "update": "Update",
       "submitting": "Submitting...",
@@ -219,7 +242,8 @@
           "cx": "Custom Search Engine ID",
           "cxPlaceholder": "Enter search engine ID",
           "baseUrl": "Base URL",
-          "baseUrlPlaceholder": "Enter base URL"
+          "baseUrlPlaceholder": "Enter base URL",
+          "baseUrlOptionalHelp": "When left empty, the provider's default URL will be used"
         },
         "forceSearch": "Force search on every request",
         "needReference": "Include references in response",
@@ -246,6 +270,141 @@
       "deleting": "Deleting..."
     }
   },
+  "mcp": {
+    "management": "MCP Management",
+    "title": "Message Control Protocol",
+    "add": "Add MCP",
+    "edit": "Edit",
+    "delete": "Delete",
+    "enable": "Enable",
+    "disable": "Disable",
+    "refresh": "Refresh",
+    "id": "ID",
+    "name": "Name",
+    "type": "Type",
+    "status": "Status",
+    "endpoint": "Endpoint",
+    "tags": "Tags",
+    "description": "Description",
+    "noResults": "No MCPs found",
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "list": {
+      "title": "Available MCPs",
+      "description": "List of available Message Control Protocols",
+      "search": "Search by name, ID, or tag",
+      "viewDetails": "View Details",
+      "noResults": "No MCPs found matching your search",
+      "endpointsSse": "SSE Endpoint",
+      "endpointsHttp": "HTTP Endpoint",
+      "createdAt": "Created",
+      "updatedAt": "Last Updated"
+    },
+    "embed": {
+      "title": "Embedded MCP Servers",
+      "description": "Manage embedded MCP server instances",
+      "status": "Server Status",
+      "running": "Running",
+      "stopped": "Stopped",
+      "start": "Start Server",
+      "stop": "Stop Server",
+      "startingServer": "Starting MCP server...",
+      "stoppingServer": "Stopping MCP server...",
+      "serverUrl": "Server URL",
+      "noEmbeddedServers": "No embedded MCP servers available",
+      "configSaved": "configuration saved successfully",
+      "saveError": "Failed to save MCP configuration"
+    },
+    "config": {
+      "title": "MCP Configuration",
+      "createTitle": "Create MCP",
+      "updateTitle": "Update MCP",
+      "createDescription": "Configure a new Message Control Protocol",
+      "updateDescription": "Update an existing MCP configuration",
+      "basicInfo": "Basic Information",
+      "id": "ID",
+      "idPlaceholder": "Enter unique identifier for this MCP",
+      "name": "Name",
+      "namePlaceholder": "Enter display name",
+      "type": "Type",
+      "selectType": "Select MCP type",
+      "tags": "Tags",
+      "tagsPlaceholder": "Add tags",
+      "addTag": "Add",
+      "readme": "Description (Markdown)",
+      "readmePlaceholder": "Enter description using Markdown format",
+      "status": "Status",
+      "statusEnabled": "Enabled",
+      "statusDisabled": "Disabled",
+      "submit": "Save Configuration",
+      "cancel": "Cancel",
+      "delete": "Delete",
+      "deleteConfirm": "Are you sure you want to delete this MCP?",
+      "deleteConfirmMessage": "This action cannot be undone.",
+      "typeConfig": {
+        "title": "Type-specific Configuration",
+        "proxy": {
+          "title": "Proxy Configuration",
+          "url": "URL",
+          "urlPlaceholder": "Enter target URL",
+          "headers": "Headers",
+          "addHeader": "Add Header",
+          "headerName": "Name",
+          "headerValue": "Value",
+          "headerNamePlaceholder": "Enter header name",
+          "headerValuePlaceholder": "Enter header value",
+          "queryParams": "Query Parameters",
+          "addQueryParam": "Add Parameter",
+          "paramName": "Name",
+          "paramValue": "Value",
+          "paramNamePlaceholder": "Enter parameter name",
+          "paramValuePlaceholder": "Enter parameter value",
+          "reuseParams": "Reuse Parameters",
+          "reuseParamsDescription": "Reuse query parameters from the original request"
+        },
+        "openapi": {
+          "title": "OpenAPI Configuration",
+          "spec": "OpenAPI Specification",
+          "specPlaceholder": "Enter OpenAPI specification URL or JSON",
+          "server": "Server",
+          "serverPlaceholder": "Enter server URL",
+          "authorization": "Authorization",
+          "authorizationPlaceholder": "Enter authorization details"
+        },
+        "docs": {
+          "title": "Documentation Configuration",
+          "content": "Documentation Content",
+          "contentPlaceholder": "Enter documentation content using Markdown"
+        },
+        "embed": {
+          "title": "Embedded Server",
+          "server": "Server",
+          "selectServer": "Select server",
+          "noServers": "No embedded servers available",
+          "restrictedEditing": "Embedded MCPs have restricted editing capabilities"
+        }
+      },
+      "validationErrors": {
+        "required": "This field is required",
+        "invalidUrl": "Please enter a valid URL",
+        "duplicateId": "This ID is already in use"
+      },
+      "typeBadge": {
+        "proxy_sse": "Proxy SSE",
+        "proxy_streamable": "Proxy Streamable",
+        "openapi": "OpenAPI",
+        "docs": "Documentation",
+        "mcp_embed": "Embedded"
+      }
+    },
+    "deleteDialog": {
+      "confirmTitle": "Delete MCP",
+      "confirmDescription": "Are you sure you want to delete this MCP? This action cannot be undone.",
+      "cancel": "Cancel",
+      "delete": "Delete",
+      "deleting": "Deleting..."
+    }
+  },
   "log": {
     "title": "Log List",
     "keyName": "Key Name",
@@ -317,6 +476,7 @@
     "disable": "Disable",
     "refresh": "Refresh",
     "noResults": "No channels found",
+    "sets": "Groups",
     "dialog": {
       "createTitle": "Create Channel",
       "updateTitle": "Update Channel",
@@ -330,11 +490,14 @@
       "keyPlaceholder": "Enter API key",
       "baseUrl": "Base URL",
       "baseUrlPlaceholder": "Enter base URL",
+      "baseUrlOptionalHelp": "When left empty, the provider's default URL will be used",
       "models": "Models",
       "selectModels": "Select models",
       "createModel": "Create Model",
       "modelMapping": "Model Mapping",
       "mappedName": "Mapped name",
+      "sets": "Groups",
+      "setsPlaceholder": "Enter group name and press enter to add",
       "create": "Create",
       "update": "Update",
       "submitting": "Submitting..."

+ 195 - 33
web/public/locales/zh/translation.json

@@ -4,6 +4,7 @@
     "key": "API Keys",
     "channel": "渠道",
     "model": "模型",
+    "mcp": "消息控制协议",
     "log": "日志",
     "doc": "文档",
     "github": "GitHub",
@@ -20,7 +21,9 @@
     },
     "global": "全局",
     "loading": "加载中...",
-    "selectDateRange": "选择日期范围"
+    "selectDateRange": "选择日期范围",
+    "success": "成功",
+    "optional": "(可选)"
   },
   "error": {
     "loading": "数据加载失败",
@@ -154,6 +157,11 @@
     "modelType": "模型类型",
     "owner": "所有者",
     "rpm": "每分钟请求数",
+    "tpm": "每分钟令牌数",
+    "retryTimes": "重试次数",
+    "timeout": "超时时间",
+    "maxErrorRate": "最大错误率",
+    "forceSaveDetail": "强制保存详情",
     "add": "添加模型",
     "edit": "编辑",
     "delete": "删除",
@@ -167,6 +175,10 @@
     "cachePlugin": "缓存",
     "webSearchPlugin": "网络搜索",
     "thinkSplitPlugin": "思考拆分",
+    "accessibleSets": "可访问组",
+    "loading": "加载中...",
+    "noChannel": "无可用渠道",
+    "availableChannels": "可用渠道",
     "dialog": {
       "createTitle": "创建模型",
       "updateTitle": "更新模型",
@@ -176,6 +188,17 @@
       "modelNamePlaceholder": "输入模型名称",
       "modelType": "模型类型",
       "selectType": "选择模型类型",
+      "rpm": "RPM(每分钟请求数)",
+      "rpmPlaceholder": "输入RPM限制",
+      "tpm": "TPM(每分钟令牌数)",
+      "tpmPlaceholder": "输入TPM限制",
+      "retryTimes": "重试次数",
+      "retryTimesPlaceholder": "重试尝试次数",
+      "timeout": "超时时间(秒)",
+      "timeoutPlaceholder": "请求超时时间(秒)",
+      "maxErrorRate": "最大错误率(0-1)",
+      "maxErrorRatePlaceholder": "最大错误率(0-1)",
+      "forceSaveDetail": "强制保存详情",
       "create": "创建",
       "update": "更新",
       "submitting": "提交中...",
@@ -246,45 +269,180 @@
       "deleting": "删除中..."
     }
   },
+  "mcp": {
+    "management": "MCP 管理",
+    "title": "消息控制协议",
+    "add": "添加 MCP",
+    "edit": "编辑",
+    "delete": "删除",
+    "enable": "启用",
+    "disable": "禁用",
+    "refresh": "刷新",
+    "id": "ID",
+    "name": "名称",
+    "type": "类型",
+    "status": "状态",
+    "endpoint": "端点",
+    "tags": "标签",
+    "description": "描述",
+    "noResults": "未找到 MCP",
+    "enabled": "已启用",
+    "disabled": "已禁用",
+    "list": {
+      "title": "可用 MCP",
+      "description": "可用消息控制协议列表",
+      "search": "按名称、ID 或标签搜索",
+      "viewDetails": "查看详情",
+      "noResults": "未找到符合搜索条件的 MCP",
+      "endpointsSse": "SSE 端点",
+      "endpointsHttp": "HTTP 端点",
+      "createdAt": "创建时间",
+      "updatedAt": "最后更新时间"
+    },
+    "embed": {
+      "title": "嵌入式 MCP 服务器",
+      "description": "管理嵌入式 MCP 服务器实例",
+      "status": "服务器状态",
+      "running": "运行中",
+      "stopped": "已停止",
+      "start": "启动服务器",
+      "stop": "停止服务器",
+      "startingServer": "正在启动 MCP 服务器...",
+      "stoppingServer": "正在停止 MCP 服务器...",
+      "serverUrl": "服务器 URL",
+      "noEmbeddedServers": "没有可用的嵌入式 MCP 服务器",
+      "configSaved": "配置保存成功",
+      "saveError": "保存 MCP 配置失败"
+    },
+    "config": {
+      "title": "MCP 配置",
+      "createTitle": "创建 MCP",
+      "updateTitle": "更新 MCP",
+      "createDescription": "配置新的消息控制协议",
+      "updateDescription": "更新现有 MCP 配置",
+      "basicInfo": "基本信息",
+      "id": "ID",
+      "idPlaceholder": "输入此 MCP 的唯一标识符",
+      "name": "名称",
+      "namePlaceholder": "输入显示名称",
+      "type": "类型",
+      "selectType": "选择 MCP 类型",
+      "tags": "标签",
+      "tagsPlaceholder": "添加标签",
+      "addTag": "添加",
+      "readme": "描述(Markdown)",
+      "readmePlaceholder": "使用 Markdown 格式输入描述",
+      "status": "状态",
+      "statusEnabled": "已启用",
+      "statusDisabled": "已禁用",
+      "submit": "保存配置",
+      "cancel": "取消",
+      "delete": "删除",
+      "deleteConfirm": "确定要删除此 MCP 吗?",
+      "deleteConfirmMessage": "此操作无法撤消。",
+      "typeConfig": {
+        "title": "类型特定配置",
+        "proxy": {
+          "title": "代理配置",
+          "url": "URL",
+          "urlPlaceholder": "输入目标 URL",
+          "headers": "请求头",
+          "addHeader": "添加请求头",
+          "headerName": "名称",
+          "headerValue": "值",
+          "headerNamePlaceholder": "输入请求头名称",
+          "headerValuePlaceholder": "输入请求头值",
+          "queryParams": "查询参数",
+          "addQueryParam": "添加参数",
+          "paramName": "名称",
+          "paramValue": "值",
+          "paramNamePlaceholder": "输入参数名称",
+          "paramValuePlaceholder": "输入参数值",
+          "reuseParams": "重用参数",
+          "reuseParamsDescription": "重用原始请求中的查询参数"
+        },
+        "openapi": {
+          "title": "OpenAPI 配置",
+          "spec": "OpenAPI 规范",
+          "specPlaceholder": "输入 OpenAPI 规范 URL 或 JSON",
+          "server": "服务器",
+          "serverPlaceholder": "输入服务器 URL",
+          "authorization": "授权",
+          "authorizationPlaceholder": "输入授权详情"
+        },
+        "docs": {
+          "title": "文档配置",
+          "content": "文档内容",
+          "contentPlaceholder": "使用 Markdown 输入文档内容"
+        },
+        "embed": {
+          "title": "嵌入式服务器",
+          "server": "服务器",
+          "selectServer": "选择服务器",
+          "noServers": "没有可用的嵌入式服务器",
+          "restrictedEditing": "嵌入式 MCP 具有受限的编辑功能"
+        }
+      },
+      "validationErrors": {
+        "required": "此字段是必填项",
+        "invalidUrl": "请输入有效的 URL",
+        "duplicateId": "此 ID 已被使用"
+      },
+      "typeBadge": {
+        "proxy_sse": "代理 SSE",
+        "proxy_streamable": "代理流式",
+        "openapi": "OpenAPI",
+        "docs": "文档",
+        "mcp_embed": "嵌入式"
+      }
+    },
+    "deleteDialog": {
+      "confirmTitle": "删除 MCP",
+      "confirmDescription": "确定要删除这个 MCP 吗?此操作无法撤消。",
+      "cancel": "取消",
+      "delete": "删除",
+      "deleting": "删除中..."
+    }
+  },
   "log": {
     "title": "日志列表",
-    "keyName": "密钥名称",
+    "keyName": "Key 名称",
     "model": "模型",
-    "inputTokens": "输入Tokens",
-    "outputTokens": "输出Tokens",
-    "duration": "持续时间(秒)",
+    "inputTokens": "输入 Token 数",
+    "outputTokens": "输出 Token 数",
+    "duration": "耗时(秒)",
     "state": "状态",
     "time": "时间",
     "details": "详情",
     "success": "成功",
     "failed": "失败",
     "basicInfo": "基本信息",
-    "tokenInfo": "Token信息",
+    "tokenInfo": "Token 信息",
     "timeInfo": "时间信息",
     "requestBody": "请求内容",
     "responseBody": "响应内容",
-    "noRequestBody": "无可用的请求内容",
-    "noResponseBody": "无可用的响应内容",
+    "noRequestBody": "无请求内容",
+    "noResponseBody": "无响应内容",
     "contentTruncated": "内容已截断",
     "id": "ID",
-    "requestId": "请求ID",
+    "requestId": "请求 ID",
     "channel": "渠道",
     "user": "用户",
-    "ip": "IP地址",
+    "ip": "IP",
     "endpoint": "端点",
     "cacheCreation": "缓存创建",
-    "cached": "已缓存",
-    "imageInput": "图像输入",
+    "cached": "缓存",
+    "imageInput": "图输入",
     "reasoning": "推理",
     "total": "总计",
     "webSearchCount": "网络搜索次数",
     "created": "创建时间",
-    "request": "请求时间",
-    "retry": "重试时间",
+    "request": "请求",
+    "retry": "重试",
     "retryTimes": "重试次数",
     "ttfb": "首字节时间",
     "filters": {
-      "keyPlaceholder": "输入密钥名称进行筛选",
+      "keyPlaceholder": "输入 Key 名称进行筛选",
       "modelPlaceholder": "输入模型名称进行筛选",
       "statusAll": "全部",
       "statusSuccess": "成功",
@@ -297,16 +455,16 @@
   "apiDoc": {
     "requestExample": "请求示例",
     "voice": "必填",
-    "voiceValues": "可用选项:",
-    "responseFormatValues": "音频输出支持的格式有:",
+    "voiceValues": "可用选项:",
+    "responseFormatValues": "音频输出支持以下格式:",
     "responseExample": "响应示例"
   },
   "channel": {
     "management": "渠道管理",
     "id": "ID",
     "name": "名称",
-    "type": "商",
-    "requestCount": "调用次数",
+    "type": "提供商",
+    "requestCount": "请求次数",
     "status": "状态",
     "enabled": "已启用",
     "disabled": "已禁用",
@@ -317,24 +475,28 @@
     "disable": "禁用",
     "refresh": "刷新",
     "noResults": "未找到渠道",
+    "sets": "分组",
     "dialog": {
       "createTitle": "创建渠道",
       "updateTitle": "更新渠道",
       "createDescription": "向系统添加新渠道",
       "updateDescription": "更新渠道信息",
-      "type": "商",
-      "selectType": "选择商",
+      "type": "提供商",
+      "selectType": "选择提供商",
       "name": "自定义名称",
       "namePlaceholder": "输入自定义名称",
-      "key": "密钥",
-      "keyPlaceholder": "输入密钥",
-      "baseUrl": "代理地址",
-      "baseUrlPlaceholder": "输入代理地址",
+      "key": "API Key",
+      "keyPlaceholder": "输入 API Key",
+      "baseUrl": "基础 URL",
+      "baseUrlPlaceholder": "输入基础 URL",
+      "baseUrlOptionalHelp": "当不填写时,将使用厂商默认地址",
       "models": "模型",
       "selectModels": "选择模型",
       "createModel": "创建模型",
       "modelMapping": "模型映射",
       "mappedName": "映射名称",
+      "sets": "分组",
+      "setsPlaceholder": "输入分组名称并回车添加",
       "create": "创建",
       "update": "更新",
       "submitting": "提交中..."
@@ -349,17 +511,17 @@
   },
   "modeType": {
     "0": "未知",
-    "1": "聊天补全",
-    "2": "文本补全",
-    "3": "文本嵌入",
+    "1": "聊天",
+    "2": "文本",
+    "3": "向量化",
     "4": "内容审核",
-    "5": "图像生成",
-    "6": "文本编辑",
+    "5": "图像",
+    "6": "编辑",
     "7": "语音合成",
-    "8": "语音转录",
-    "9": "音频翻译",
+    "8": "语音识别",
+    "9": "音频",
     "10": "重排序",
-    "11": "pdf解析",
+    "11": "PDF解析",
     "13": "视频生成"
   }
 }

+ 7 - 1
web/src/api/log.ts

@@ -1,5 +1,5 @@
 import { get } from './index'
-import { LogResponse, LogFilters } from '@/types/log'
+import { LogResponse, LogFilters, LogRequestDetail } from '@/types/log'
 
 export const logApi = {
     // 获取全部日志数据
@@ -74,5 +74,11 @@ export const logApi = {
             // 没有keyName时使用全局API
             return logApi.getLogs(filters)
         }
+    },
+    
+    // 获取日志详情
+    getLogDetail: async (logId: number): Promise<LogRequestDetail> => {
+        const response = await get<LogRequestDetail>(`logs/detail/${logId}`)
+        return response
     }
 } 

+ 145 - 0
web/src/api/mcp.ts

@@ -0,0 +1,145 @@
+import { get, post, put, del } from './index'
+
+// Types
+export interface ReusingParam {
+  name: string
+  description: string
+  required: boolean
+  type: 'header' | 'query'
+}
+
+export interface PublicMCPProxyConfig {
+  url: string
+  querys: Record<string, string>
+  headers: Record<string, string>
+  reusing_params: Record<string, ReusingParam>
+}
+
+export interface MCPOpenAPIConfig {
+  openapi_spec: string
+  openapi_content?: string
+  v2: boolean
+  server_addr?: string
+  authorization?: string
+}
+
+export interface MCPEmbeddingReusingConfig {
+  name: string
+  description: string
+  required: boolean
+}
+
+export interface MCPEmbeddingConfig {
+  init: Record<string, string>
+  reusing: Record<string, MCPEmbeddingReusingConfig>
+}
+
+export interface PublicMCP {
+  id: string
+  name: string
+  status: number
+  type: string
+  created_at: number
+  update_at: number
+  readme: string
+  tags: string[]
+  logo_url: string
+  proxy_config?: PublicMCPProxyConfig
+  openapi_config?: MCPOpenAPIConfig
+  embed_config?: MCPEmbeddingConfig
+  endpoints: {
+    host: string
+    sse: string
+    streamable_http: string
+  }
+}
+
+export interface MCPListResponse {
+  mcps: PublicMCP[]
+  total: number
+}
+
+export interface EmbedMCPConfigTemplate {
+  name: string
+  required: boolean
+  example: string
+  description: string
+}
+
+export interface EmbedMCP {
+  id: string
+  enabled: boolean
+  name: string
+  readme: string
+  tags: string[]
+  config_templates: Record<string, EmbedMCPConfigTemplate>
+}
+
+export interface SaveEmbedMCPRequest {
+  id: string
+  enabled: boolean
+  init_config: Record<string, string>
+}
+
+export interface PublicMCPReusingParam {
+  mcp_id: string
+  group_id: string
+  reusing_params: Record<string, string>
+}
+
+// API functions
+export const getMCPs = (params: {
+  page: number
+  per_page: number
+  type?: string
+  keyword?: string
+  status?: number
+}) => {
+  return get<MCPListResponse>('/mcp/public/', { params })
+}
+
+export const getAllMCPs = (params?: { status?: number }) => {
+  return get<PublicMCP[]>('/mcp/public/all', { params })
+}
+
+export const getMCPById = (id: string) => {
+  return get<PublicMCP>(`/mcp/public/${id}`)
+}
+
+export const createMCP = (data: PublicMCP) => {
+  return post<PublicMCP>('/mcp/public/', data)
+}
+
+export const updateMCP = (id: string, data: PublicMCP) => {
+  return put<PublicMCP>(`/mcp/public/${id}`, data)
+}
+
+export const updateMCPStatus = (id: string, status: number) => {
+  return post(`/mcp/public/${id}/status`, { status })
+}
+
+export const deleteMCP = (id: string) => {
+  return del(`/mcp/public/${id}`)
+}
+
+// Embed MCP API functions
+export const getEmbedMCPs = () => {
+  return get<EmbedMCP[]>('/embedmcp/')
+}
+
+export const saveEmbedMCP = (data: SaveEmbedMCPRequest) => {
+  return post('/embedmcp/', data)
+}
+
+// MCP Reusing Params API functions
+export const getMCPReusingParams = (mcpId: string, groupId: string) => {
+  return get<PublicMCPReusingParam>(`/mcp/public/${mcpId}/group/${groupId}/params`)
+}
+
+export const saveMCPReusingParams = (
+  mcpId: string,
+  groupId: string,
+  data: PublicMCPReusingParam
+) => {
+  return post(`/mcp/public/${mcpId}/group/${groupId}/params`, data)
+} 

+ 42 - 19
web/src/api/model.ts

@@ -1,26 +1,49 @@
 // src/api/model.ts
-import { get, post, del } from './index'
-import { ModelConfig, ModelCreateRequest } from '@/types/model'
+import { get, post, del, put } from "./index";
+import { ModelConfig, ModelCreateRequest } from "@/types/model";
 
+// Define the type for model sets response
+export interface ModelSetsResponse {
+  [modelName: string]: {
+    [setName: string]: Array<{
+      id: number;
+      type: number;
+      name: string;
+    }>;
+  };
+}
 
 export const modelApi = {
-    getModels: async (): Promise<ModelConfig[]> => {
-        const response = await get<ModelConfig[]>('model_configs/all')
-        return response
-    },
+  getModels: async (): Promise<ModelConfig[]> => {
+    const response = await get<ModelConfig[]>("model_configs/all");
+    return response;
+  },
 
-    getModel: async (model: string): Promise<ModelConfig> => {
-        const response = await get<ModelConfig>(`model_config/${model}`)
-        return response
-    },
+  getModel: async (model: string): Promise<ModelConfig> => {
+    const response = await get<ModelConfig>(`model_config/${model}`);
+    return response;
+  },
 
-    createModel: async (data: ModelCreateRequest): Promise<void> => {
-        await post('model_config/', data)
-        return
-    },
+  getModelSets: async () => {
+    const response = await get<ModelSetsResponse>("models/sets");
+    return response;
+  },
 
-    deleteModel: async (model: string): Promise<void> => {
-        await del(`model_config/${model}`)
-        return
-    }
-}
+  createModel: async (data: ModelCreateRequest): Promise<void> => {
+    await post("model_config/", data);
+    return;
+  },
+
+  updateModel: async (
+    model: string,
+    data: Omit<ModelCreateRequest, "model">
+  ): Promise<void> => {
+    await put(`model_config/${model}`, data);
+    return;
+  },
+
+  deleteModel: async (model: string): Promise<void> => {
+    await del(`model_config/${model}`);
+    return;
+  },
+};

+ 29 - 0
web/src/components/common/CopyButton.tsx

@@ -0,0 +1,29 @@
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Check, Copy } from 'lucide-react'
+
+interface CopyButtonProps {
+  text: string
+  className?: string
+}
+
+export const CopyButton = ({ text, className }: CopyButtonProps) => {
+  const [copied, setCopied] = useState(false)
+
+  const copyToClipboard = () => {
+    navigator.clipboard.writeText(text)
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <Button
+      size="sm"
+      variant="ghost"
+      className={className}
+      onClick={copyToClipboard}
+    >
+      {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
+    </Button>
+  )
+} 

+ 9 - 0
web/src/components/layout/SideBar.tsx

@@ -12,6 +12,7 @@ import {
     FileText,
     Github,
     LogOut,
+    MessageCircle,
 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import type { TFunction } from "i18next"
@@ -55,6 +56,12 @@ function createSidebarConfig(t: TFunction): SidebarItem[] {
             href: ROUTES.MODEL,
             display: true,
         },
+        {
+            title: t("sidebar.mcp"),
+            icon: MessageCircle,
+            href: ROUTES.MCP,
+            display: true,
+        },
         {
             title: t("sidebar.log"),
             icon: Calendar,
@@ -83,6 +90,7 @@ interface SidebarDisplayConfig {
     key?: boolean
     channel?: boolean
     model?: boolean
+    mcp?: boolean
     log?: boolean
     doc?: boolean
     github?: boolean
@@ -108,6 +116,7 @@ export function Sidebar({ displayConfig = {}, collapsed = false, onToggle }: Sid
         if (item.href === ROUTES.KEY) configKey = "key"
         if (item.href === ROUTES.CHANNEL) configKey = "channel"
         if (item.href === ROUTES.MODEL) configKey = "model"
+        if (item.href === ROUTES.MCP) configKey = "mcp"
         if (item.href === ROUTES.LOG) configKey = "log"
         if (item.href === "https://sealos.run/docs/guides/ai-proxy") configKey = "doc"
         if (item.href === "https://github.com/labring/aiproxy") configKey = "github"

+ 17 - 3
web/src/components/select/MultiSelectCombobox.tsx

@@ -14,6 +14,9 @@ export const MultiSelectCombobox = function <T>({
     handleFilteredDropdownItems,
     handleDropdownItemDisplay,
     handleSelectedItemDisplay,
+    allowUserCreatedItems = false,
+    placeholder,
+    label
 }: {
     dropdownItems: T[]
     selectedItems: T[]
@@ -21,6 +24,9 @@ export const MultiSelectCombobox = function <T>({
     handleFilteredDropdownItems: (dropdownItems: T[], selectedItems: T[], inputValue: string) => T[]
     handleDropdownItemDisplay: (dropdownItem: T) => ReactNode
     handleSelectedItemDisplay: (selectedItem: T) => ReactNode
+    allowUserCreatedItems?: boolean
+    placeholder?: string
+    label?: string
 }): JSX.Element {
     const { t } = useTranslation()
 
@@ -82,8 +88,16 @@ export const MultiSelectCombobox = function <T>({
         onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
             switch (type) {
                 case useCombobox.stateChangeTypes.InputKeyDownEnter:
+                    if (allowUserCreatedItems && newInputValue && newInputValue.trim() !== '') {
+                        // If user created items are allowed and we have input, add it as a new item
+                        setSelectedItems([...selectedItems, newInputValue as unknown as T])
+                        setInputValue('')
+                    } else if (newSelectedItem) {
+                        setSelectedItems([...selectedItems, newSelectedItem])
+                        setInputValue('')
+                    }
+                    break;
                 case useCombobox.stateChangeTypes.ItemClick:
-                case useCombobox.stateChangeTypes.InputBlur:
                     if (newSelectedItem) {
                         setSelectedItems([...selectedItems, newSelectedItem])
                         setInputValue('')
@@ -109,7 +123,7 @@ export const MultiSelectCombobox = function <T>({
                 >
                     <div className="flex gap-0.5 items-start">
                         <span className="whitespace-nowrap text-sm font-medium leading-5 tracking-[0.1px]">
-                            {t('channel.dialog.models')}
+                            {label || t('channel.dialog.models')}
                         </span>
                     </div>
                 </Label>
@@ -148,7 +162,7 @@ export const MultiSelectCombobox = function <T>({
                         <div className="flex flex-1 gap-1">
                             <Input
                                 className="border-none shadow-none h-auto p-0 text-xs font-normal leading-4 tracking-[0.048px] bg-transparent"
-                                placeholder={t('channel.dialog.selectModels')}
+                                placeholder={placeholder || t('channel.dialog.selectModels')}
                                 {...getInputProps(getDropdownProps({ preventKeyAction: isOpen }))}
                             />
 

+ 52 - 0
web/src/components/ui/tabs.tsx

@@ -0,0 +1,52 @@
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+import { cn } from "@/lib/utils"
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.List>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.List
+    ref={ref}
+    className={cn(
+      "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
+      className
+    )}
+    {...props}
+  />
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Trigger
+    ref={ref}
+    className={cn(
+      "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
+      className
+    )}
+    {...props}
+  />
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+  React.ElementRef<typeof TabsPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => (
+  <TabsPrimitive.Content
+    ref={ref}
+    className={cn(
+      "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+      className
+    )}
+    {...props}
+  />
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+export { Tabs, TabsList, TabsTrigger, TabsContent } 

+ 24 - 0
web/src/components/ui/textarea.tsx

@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+  HTMLTextAreaElement,
+  React.TextareaHTMLAttributes<HTMLTextAreaElement>
+>(
+  ({ className, ...props }, ref) => {
+    return (
+      <textarea
+        className={cn(
+          "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+          className
+        )}
+        ref={ref}
+        {...props}
+      />
+    )
+  }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea } 

+ 177 - 0
web/src/components/ui/use-toast.tsx

@@ -0,0 +1,177 @@
+// Inspired by react-hot-toast library
+import * as React from "react"
+import { useState, useEffect } from "react"
+
+const TOAST_LIMIT = 5
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToastActionElement = React.ReactElement
+
+export type Toast = {
+  id?: string
+  title?: string
+  description?: string
+  action?: ToastActionElement
+  variant?: "default" | "destructive"
+}
+
+type ToasterToast = Toast & {
+  id: string
+  dismiss: () => void
+}
+
+const actionTypes = {
+  ADD_TOAST: "ADD_TOAST",
+  UPDATE_TOAST: "UPDATE_TOAST",
+  DISMISS_TOAST: "DISMISS_TOAST",
+  REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+type ActionType = typeof actionTypes
+
+type Action =
+  | {
+      type: ActionType["ADD_TOAST"]
+      toast: Toast
+    }
+  | {
+      type: ActionType["UPDATE_TOAST"]
+      toast: Partial<Toast>
+    }
+  | {
+      type: ActionType["DISMISS_TOAST"]
+      toastId?: string
+    }
+  | {
+      type: ActionType["REMOVE_TOAST"]
+      toastId?: string
+    }
+
+type State = {
+  toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
+
+const addToRemoveQueue = (toastId: string) => {
+  if (toastTimeouts.has(toastId)) {
+    return
+  }
+
+  const timeout = setTimeout(() => {
+    toastTimeouts.delete(toastId)
+    dispatch({
+      type: actionTypes.REMOVE_TOAST,
+      toastId,
+    })
+  }, TOAST_REMOVE_DELAY)
+
+  toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+  switch (action.type) {
+    case actionTypes.ADD_TOAST:
+      return {
+        ...state,
+        toasts: [
+          ...state.toasts,
+          {
+            ...action.toast,
+            id: action.toast.id || String(Date.now()),
+            dismiss: () =>
+              dispatch({
+                type: actionTypes.DISMISS_TOAST,
+                toastId: action.toast.id,
+              }),
+          },
+        ].slice(0, TOAST_LIMIT),
+      }
+
+    case actionTypes.UPDATE_TOAST:
+      return {
+        ...state,
+        toasts: state.toasts.map((t) =>
+          t.id === action.toast.id
+            ? { ...t, ...action.toast }
+            : t
+        ),
+      }
+
+    case actionTypes.DISMISS_TOAST: {
+      const { toastId } = action
+
+      // ! Side effects ! - This could be extracted into a dismissToast() action,
+      // but I'll keep it here for simplicity
+      if (toastId) {
+        addToRemoveQueue(toastId)
+      } else {
+        state.toasts.forEach((toast) => {
+          addToRemoveQueue(toast.id)
+        })
+      }
+
+      return {
+        ...state,
+        toasts: state.toasts.map((t) =>
+          t.id === toastId || toastId === undefined
+            ? {
+                ...t,
+                dismissed: true,
+              }
+            : t
+        ),
+      }
+    }
+    case actionTypes.REMOVE_TOAST:
+      if (action.toastId === undefined) {
+        return {
+          ...state,
+          toasts: [],
+        }
+      }
+      return {
+        ...state,
+        toasts: state.toasts.filter((t) => t.id !== action.toastId),
+      }
+  }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+  memoryState = reducer(memoryState, action)
+  listeners.forEach((listener) => {
+    listener(memoryState)
+  })
+}
+
+export function useToast() {
+  const [state, setState] = useState<State>(memoryState)
+
+  useEffect(() => {
+    listeners.push(setState)
+    return () => {
+      const index = listeners.indexOf(setState)
+      if (index > -1) {
+        listeners.splice(index, 1)
+      }
+    }
+  }, [state])
+
+  return {
+    ...state,
+    toast: (props: Toast) => {
+      dispatch({
+        type: actionTypes.ADD_TOAST,
+        toast: props,
+      })
+    },
+    dismiss: (toastId?: string) => dispatch({
+      type: actionTypes.DISMISS_TOAST,
+      toastId,
+    }),
+  }
+} 

+ 17 - 2
web/src/feature/channel/components/ChannelDialog.tsx

@@ -32,6 +32,8 @@ export function ChannelDialog({
 }: ChannelDialogProps) {
     const { t } = useTranslation()
 
+    console.log('ChannelDialog opened with mode:', mode, 'channel:', channel);
+
     // Determine title and description based on mode
     const title = mode === 'create' ? t("channel.dialog.createTitle") : t("channel.dialog.updateTitle")
     const description = mode === 'create'
@@ -46,7 +48,8 @@ export function ChannelDialog({
             key: channel.key,
             base_url: channel.base_url,
             models: channel.models || [],
-            model_mapping: channel.model_mapping || {}
+            model_mapping: channel.model_mapping || {},
+            sets: channel.sets || []
         }
         : {
             type: 0,
@@ -54,8 +57,20 @@ export function ChannelDialog({
             key: '',
             base_url: '',
             models: [],
-            model_mapping: {}
+            model_mapping: {},
+            sets: []
+        }
+
+    // Log for debugging
+    if (mode === 'update') {
+        console.log('Update mode detected. Channel ID:', channel?.id);
+        // Make sure channel ID exists
+        if (!channel || !channel.id) {
+            console.error('ERROR: No channel ID available for update!');
+        } else {
+            console.log('Will pass channelId:', channel.id, 'to ChannelForm');
         }
+    }
 
     return (
         <Dialog open={open} onOpenChange={onOpenChange}>

+ 124 - 20
web/src/feature/channel/components/ChannelForm.tsx

@@ -25,6 +25,7 @@ import { ConstructMappingComponent } from '@/components/select/ConstructMappingC
 import { AdvancedErrorDisplay } from '@/components/common/error/errorDisplay'
 import { Skeleton } from "@/components/ui/skeleton"
 import { AnimatedContainer } from '@/components/ui/animation/components/animated-container'
+import { toast } from 'sonner'
 
 interface ChannelFormProps {
     mode?: 'create' | 'update'
@@ -35,16 +36,16 @@ interface ChannelFormProps {
         type: number
         name: string
         key: string
-        base_url: string
+        base_url?: string
         models: string[]
         model_mapping?: Record<string, string>
+        sets?: string[]
     }
 }
 
 export function ChannelForm({
     mode = 'create',
     channelId,
-    // @ts-expect-error 忽略未使用参数
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     channel,
     onSuccess,
@@ -54,11 +55,16 @@ export function ChannelForm({
         key: '',
         base_url: '',
         models: [],
-        model_mapping: {}
+        model_mapping: {},
+        sets: []
     },
 }: ChannelFormProps) {
     const { t } = useTranslation()
     const [modelDialogOpen, setModelDialogOpen] = useState(false)
+    const [isUserSubmitting, setIsUserSubmitting] = useState(false)
+
+    // Log component props for debugging
+    console.log('ChannelForm rendered with props:', { mode, channelId, hasChannel: !!channel });
 
     // 获取渠道类型元数据
     const { data: typeMetas, isLoading: isTypeMetasLoading } = useChannelTypeMetas()
@@ -66,7 +72,6 @@ export function ChannelForm({
     // 获取所有模型
     const { data: models, isLoading: isModelsLoading } = useModels()
 
-
     // API hooks
     const {
         createChannel,
@@ -93,42 +98,92 @@ export function ChannelForm({
         defaultValues,
     })
 
-
-
+    // 防止意外的表单提交
+    const handleKeyDown = (e: React.KeyboardEvent) => {
+        if (e.key === 'Enter' && e.target !== e.currentTarget) {
+            // 如果不是在提交按钮上按 Enter,则阻止默认行为
+            const target = e.target as HTMLElement
+            if (target.tagName !== 'BUTTON' || (target as HTMLButtonElement).type !== 'submit') {
+                e.preventDefault()
+            }
+        }
+    }
 
     // 表单提交处理
     const handleFormSubmit = (data: ChannelCreateForm) => {
+        // 只有在用户主动提交时才处理
+        if (!isUserSubmitting) {
+            console.log('Form submission prevented - not explicitly triggered by user')
+            return
+        }
+        
+        setIsUserSubmitting(false) // 重置状态
+
         // 清除之前的错误
         if (clearError) clearError()
 
-
         // 准备提交数据
         const formData = {
             type: data.type,
             name: data.name,
             key: data.key,
-            base_url: data.base_url,
-            models: data.models,
-            model_mapping: data.model_mapping
+            base_url: data.base_url || '',  // Ensure base_url is never undefined for API
+            models: data.models || [],
+            model_mapping: data.model_mapping || {},
+            sets: data.sets || []
         }
 
+        console.log('Submitting form data:', { mode, channelId, formData });
+        console.dir({ mode, channelId, formData }, { depth: null });
+
         if (mode === 'create') {
             createChannel(formData, {
                 onSuccess: () => {
+                    console.log('Channel created successfully');
                     form.reset()
                     if (onSuccess) onSuccess()
+                },
+                onError: (error) => {
+                    console.error('Failed to create channel:', error);
                 }
             })
-        } else if (mode === 'update' && channelId) {
-            updateChannel({ id: channelId, data: formData }, {
+        } else if (mode === 'update') {
+            // Check for channelId
+            if (!channelId) {
+                console.error('Cannot update: missing channelId');
+                toast.error('更新失败:缺少渠道ID');
+                return;
+            }
+
+            console.log('Updating channel with ID:', channelId);
+            // Use explicit typing to ensure id is a number
+            const updateId: number = typeof channelId === 'string' ? parseInt(channelId) : channelId;
+            
+            updateChannel({ 
+                id: updateId, 
+                data: formData 
+            }, {
                 onSuccess: () => {
+                    console.log('Channel updated successfully');
+                    toast.success('渠道更新成功');
                     form.reset()
                     if (onSuccess) onSuccess()
+                },
+                onError: (error) => {
+                    console.error('Failed to update channel:', error);
+                    toast.error('更新渠道失败');
                 }
             })
+        } else {
+            console.error('Unknown mode:', mode);
         }
     }
 
+    // 处理提交按钮点击
+    const handleSubmitClick = () => {
+        setIsUserSubmitting(true)
+    }
+
     // 获取类型对应的字段提示
     const getTypeHelp = (typeId: number) => {
         if (!typeMetas || !typeId) return { keyHelp: '', defaultBaseUrl: '' }
@@ -162,6 +217,12 @@ export function ChannelForm({
                 <Skeleton className="h-32 w-full" />
             </div>
 
+            {/* 分组字段骨架 */}
+            <div className="space-y-2">
+                <Skeleton className="h-5 w-28" />
+                <Skeleton className="h-[72px] w-full rounded-md" />
+            </div>
+
             {/* 密钥字段骨架 */}
             <div className="space-y-2">
                 <Skeleton className="h-5 w-24" />
@@ -188,7 +249,11 @@ export function ChannelForm({
                     renderFormSkeleton()
                 ) : (
                     <Form {...form}>
-                        <form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
+                        <form 
+                            onSubmit={form.handleSubmit(handleFormSubmit)} 
+                            onKeyDown={handleKeyDown}
+                            className="space-y-6"
+                        >
                             {/* API错误提示 */}
                             {error && (
                                 <AdvancedErrorDisplay error={error} />
@@ -245,7 +310,6 @@ export function ChannelForm({
                                                     )
                                                 })
 
-
                                             }}
                                             handleDropdownItemDisplay={(
                                                 dropdownItem: string
@@ -274,7 +338,6 @@ export function ChannelForm({
                                 )}
                             />
 
-
                             {/* 模型选择字段 */}
                             <FormField
                                 control={form.control}
@@ -347,8 +410,6 @@ export function ChannelForm({
                                 }}
                             />
 
-
-
                             {/* 模型映射字段 */}
                             <FormField
                                 control={form.control}
@@ -368,6 +429,39 @@ export function ChannelForm({
                                 }}
                             />
 
+                            {/* 分组字段 */}
+                            <FormField
+                                control={form.control}
+                                name="sets"
+                                render={({ field }) => {
+                                    return (
+                                        <FormItem>
+                                            <FormControl>
+                                                <MultiSelectCombobox<string>
+                                                    dropdownItems={[]}
+                                                    selectedItems={field.value || []}
+                                                    setSelectedItems={(sets) => {
+                                                        field.onChange(sets)
+                                                    }}
+                                                    handleFilteredDropdownItems={(dropdownItems, selectedItems, inputValue) => {
+                                                        // 允许用户创建新的分组
+                                                        if (inputValue && !selectedItems.includes(inputValue) && !dropdownItems.includes(inputValue)) {
+                                                            return [inputValue, ...dropdownItems]
+                                                        }
+                                                        return dropdownItems
+                                                    }}
+                                                    handleDropdownItemDisplay={(item) => item}
+                                                    handleSelectedItemDisplay={(item) => item}
+                                                    allowUserCreatedItems={true}
+                                                    placeholder={t("channel.dialog.setsPlaceholder")}
+                                                    label={t("channel.dialog.sets")}
+                                                />
+                                            </FormControl>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )
+                                }}
+                            />
 
                             {/* 密钥字段 */}
                             <FormField
@@ -402,23 +496,33 @@ export function ChannelForm({
 
                                     return (
                                         <FormItem>
-                                            <FormLabel>{t("channel.dialog.baseUrl")}</FormLabel>
+                                            <div className="flex items-center gap-2">
+                                                <FormLabel>{t("channel.dialog.baseUrl")}</FormLabel>
+                                                <span className="text-xs text-muted-foreground">{t("common.optional")}</span>
+                                            </div>
                                             <FormControl>
                                                 <Input
                                                     placeholder={defaultBaseUrl || t("channel.dialog.baseUrlPlaceholder")}
                                                     {...field}
+                                                    value={field.value || ''}
                                                 />
                                             </FormControl>
+                                            <p className="text-xs text-muted-foreground mt-1">
+                                                {t("channel.dialog.baseUrlOptionalHelp")}
+                                            </p>
                                             <FormMessage />
                                         </FormItem>
                                     )
                                 }}
                             />
 
-
                             {/* 提交按钮 */}
                             <div className="flex justify-end">
-                                <Button type="submit" disabled={isLoading}>
+                                <Button 
+                                    type="submit" 
+                                    disabled={isLoading}
+                                    onClick={handleSubmitClick}
+                                >
                                     {isLoading ? t("channel.dialog.submitting") : mode === 'create' ? t("channel.dialog.create") : t("channel.dialog.update")}
                                 </Button>
                             </div>

+ 25 - 1
web/src/feature/channel/components/ChannelTable.tsx

@@ -102,8 +102,10 @@ export function ChannelTable() {
 
     // 打开更新渠道对话框
     const openUpdateDialog = (channel: Channel) => {
+        console.log('Opening update dialog for channel:', channel);
+        console.log('Channel ID to be updated:', channel.id);
         setDialogMode('update')
-        setSelectedChannel(channel)
+        setSelectedChannel({...channel}) // Create a new reference to ensure update
         setChannelDialogOpen(true)
     }
 
@@ -159,6 +161,28 @@ export function ChannelTable() {
                 </div>
             ),
         },
+        {
+            accessorKey: 'sets',
+            header: () => <div className="font-medium py-3.5 whitespace-nowrap">{t("channel.sets")}</div>,
+            cell: ({ row }) => {
+                const sets = row.original.sets || [];
+                if (sets.length === 0) return <div className="text-muted-foreground text-xs">-</div>;
+                
+                return (
+                    <div className="flex flex-wrap gap-1">
+                        {sets.map((set, index) => (
+                            <Badge 
+                                key={index} 
+                                variant="secondary" 
+                                className="text-xs py-0 px-2"
+                            >
+                                {set}
+                            </Badge>
+                        ))}
+                    </div>
+                );
+            }
+        },
         {
             accessorKey: 'request_count',
             header: () => <div className="font-medium py-3.5 whitespace-nowrap">{t("channel.requestCount")}</div>,

+ 160 - 0
web/src/feature/log/components/ExpandedLogContent.tsx

@@ -0,0 +1,160 @@
+import { useState, useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { format } from 'date-fns'
+import { Separator } from '@/components/ui/separator'
+import { JsonViewer } from './JsonViewer'
+import { useLogDetail } from '@/feature/log/hooks'
+import type { LogRecord, LogRequestDetail } from '@/types/log'
+
+// 日志详情组件 - 处理每行的展开内容
+export const ExpandedLogContent = ({ log }: { log: LogRecord }) => {
+    const { t } = useTranslation()
+    const needsDetail = !!log.request_detail
+    const [requestDetail, setRequestDetail] = useState<LogRequestDetail | null>(null)
+    
+    // 每一行都有自己的query
+    const { 
+        data: logDetail, 
+        isLoading: isLoadingDetail, 
+        error: logDetailError 
+    } = useLogDetail(needsDetail ? log.id : null)
+    
+    // 当获取到数据时更新本地state
+    useEffect(() => {
+        if (logDetail) {
+            setRequestDetail(logDetail)
+        }
+    }, [logDetail])
+    
+    // 当前的请求体和响应体
+    const requestBody = needsDetail && requestDetail ? requestDetail.request_body : null
+    const responseBody = needsDetail && requestDetail ? requestDetail.response_body : null
+    // 截断状态
+    const requestTruncated = needsDetail && requestDetail ? requestDetail.request_body_truncated : false
+    const responseTruncated = needsDetail && requestDetail ? requestDetail.response_body_truncated : false
+    // 加载中或出错
+    const isLoadingData = needsDetail && isLoadingDetail
+    const hasError = needsDetail && logDetailError
+    
+    // 计算请求耗时
+    const calculateDuration = () => {
+        if (!log.request_at || !log.created_at) return '-'
+        const requestAt = new Date(log.request_at)
+        const createdAt = new Date(log.created_at)
+        const duration = (createdAt.getTime() - requestAt.getTime()) / 1000
+        return `${duration.toFixed(2)}s`
+    }
+    
+    return (
+        <div className="p-4 space-y-4 bg-muted/50 border-t">
+            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                {/* 基本信息 */}
+                <div className="space-y-2">
+                    <h4 className="font-semibold text-sm">{t('log.basicInfo')}</h4>
+                    <div className="space-y-1 text-sm">
+                        <div><span className="font-medium">{t('log.id')}:</span> {log.id}</div>
+                        <div><span className="font-medium">{t('log.requestId')}:</span> {log.request_id}</div>
+                        <div><span className="font-medium">{t('log.keyName')}:</span> {log.token_name || '-'}</div>
+                        <div><span className="font-medium">{t('log.model')}:</span> {log.model || '-'}</div>
+                        <div><span className="font-medium">{t('log.channel')}:</span> {log.channel}</div>
+                        <div><span className="font-medium">{t('log.user')}:</span> {log.user || '-'}</div>
+                        <div><span className="font-medium">{t('log.ip')}:</span> {log.ip}</div>
+                        <div><span className="font-medium">{t('log.endpoint')}:</span> {log.endpoint}</div>
+                    </div>
+                </div>
+
+                {/* Token信息 */}
+                <div className="space-y-2">
+                    <h4 className="font-semibold text-sm">{t('log.tokenInfo')}</h4>
+                    <div className="space-y-1 text-sm">
+                        <div><span className="font-medium">{t('log.inputTokens')}:</span> {log.usage?.input_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.outputTokens')}:</span> {log.usage?.output_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.total')}:</span> {log.usage?.total_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.cacheCreation')}:</span> {log.usage?.cache_creation_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.cached')}:</span> {log.usage?.cached_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.imageInput')}:</span> {log.usage?.image_input_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.reasoning')}:</span> {log.usage?.reasoning_tokens?.toLocaleString() || 0}</div>
+                        <div><span className="font-medium">{t('log.webSearchCount')}:</span> {log.usage?.web_search_count || 0}</div>
+                    </div>
+                </div>
+
+                {/* 时间信息 */}
+                <div className="space-y-2">
+                    <h4 className="font-semibold text-sm">{t('log.timeInfo')}</h4>
+                    <div className="space-y-1 text-sm">
+                        <div><span className="font-medium">{t('log.created')}:</span> {log.created_at ? format(new Date(log.created_at), 'yyyy-MM-dd HH:mm:ss') : '-'}</div>
+                        <div><span className="font-medium">{t('log.request')}:</span> {log.request_at ? format(new Date(log.request_at), 'yyyy-MM-dd HH:mm:ss') : '-'}</div>
+                        <div><span className="font-medium">{t('log.duration')}:</span> {calculateDuration()}</div>
+                        {log.retry_at && <div><span className="font-medium">{t('log.retry')}:</span> {format(new Date(log.retry_at), 'yyyy-MM-dd HH:mm:ss')}</div>}
+                        <div><span className="font-medium">{t('log.retryTimes')}:</span> {log.retry_times || 0}</div>
+                        <div><span className="font-medium">{t('log.ttfb')}:</span> {log.ttfb_milliseconds || 0}ms</div>
+                    </div>
+                </div>
+            </div>
+
+            <Separator />
+
+            {/* 请求和响应内容 */}
+            {needsDetail && (
+                <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+                    <div>
+                        <h4 className="font-semibold text-sm mb-2">{t('log.requestBody')}</h4>
+                        {isLoadingData ? (
+                            <div className="flex items-center justify-center p-4 border rounded">
+                                <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mr-2"></div>
+                                <span className="text-sm">{t('common.loading')}</span>
+                            </div>
+                        ) : hasError ? (
+                            <div className="text-sm text-red-500 p-2 border rounded">
+                                {t('log.failed')}
+                            </div>
+                        ) : requestBody ? (
+                            <>
+                                <JsonViewer
+                                    src={requestBody}
+                                    collapsed={1}
+                                    name="request"
+                                />
+                                {requestTruncated && (
+                                    <div className="text-xs text-amber-600 mt-1">⚠️ {t('log.contentTruncated')}</div>
+                                )}
+                            </>
+                        ) : (
+                            <div className="text-sm text-muted-foreground p-2 border rounded">
+                                {t('log.noRequestBody')}
+                            </div>
+                        )}
+                    </div>
+                    <div>
+                        <h4 className="font-semibold text-sm mb-2">{t('log.responseBody')}</h4>
+                        {isLoadingData ? (
+                            <div className="flex items-center justify-center p-4 border rounded">
+                                <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mr-2"></div>
+                                <span className="text-sm">{t('common.loading')}</span>
+                            </div>
+                        ) : hasError ? (
+                            <div className="text-sm text-red-500 p-2 border rounded">
+                                {t('log.failed')}
+                            </div>
+                        ) : responseBody ? (
+                            <>
+                                <JsonViewer
+                                    src={responseBody}
+                                    collapsed={1}
+                                    name="response"
+                                />
+                                {responseTruncated && (
+                                    <div className="text-xs text-amber-600 mt-1">⚠️ {t('log.contentTruncated')}</div>
+                                )}
+                            </>
+                        ) : (
+                            <div className="text-sm text-muted-foreground p-2 border rounded">
+                                {t('log.noResponseBody')}
+                            </div>
+                        )}
+                    </div>
+                </div>
+            )}
+        </div>
+    )
+} 

+ 3 - 89
web/src/feature/log/components/LogTable.tsx

@@ -10,8 +10,7 @@ import { ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight } f
 import { format } from 'date-fns'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
-import { Separator } from '@/components/ui/separator'
-import { JsonViewer } from './JsonViewer'
+import { ExpandedLogContent } from './ExpandedLogContent'
 import type { LogRecord } from '@/types/log'
 
 const columnHelper = createColumnHelper<LogRecord>()
@@ -26,6 +25,7 @@ interface LogTableProps {
     onPageSizeChange: (pageSize: number) => void
 }
 
+// 使用一个单独的组件来处理每行的展开内容,这样每一行都有自己的state
 export function LogTable({
     data,
     total,
@@ -166,92 +166,6 @@ export function LogTable({
         pageCount: Math.ceil(total / pageSize),
     })
 
-    const renderExpandedContent = (log: LogRecord) => {
-        return (
-            <div className="p-4 space-y-4 bg-muted/50 border-t">
-                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
-                    {/* 基本信息 */}
-                    <div className="space-y-2">
-                        <h4 className="font-semibold text-sm">{t('log.basicInfo')}</h4>
-                        <div className="space-y-1 text-sm">
-                            <div><span className="font-medium">{t('log.id')}:</span> {log.id}</div>
-                            <div><span className="font-medium">{t('log.requestId')}:</span> {log.request_id}</div>
-                            <div><span className="font-medium">{t('log.channel')}:</span> {log.channel}</div>
-                            <div><span className="font-medium">{t('log.user')}:</span> {log.user || '-'}</div>
-                            <div><span className="font-medium">{t('log.ip')}:</span> {log.ip}</div>
-                            <div><span className="font-medium">{t('log.endpoint')}:</span> {log.endpoint}</div>
-                        </div>
-                    </div>
-
-                    {/* Token信息 */}
-                    <div className="space-y-2">
-                        <h4 className="font-semibold text-sm">{t('log.tokenInfo')}</h4>
-                        <div className="space-y-1 text-sm">
-                            <div><span className="font-medium">{t('log.cacheCreation')}:</span> {log.usage?.cache_creation_tokens || 0}</div>
-                            <div><span className="font-medium">{t('log.cached')}:</span> {log.usage?.cached_tokens || 0}</div>
-                            <div><span className="font-medium">{t('log.imageInput')}:</span> {log.usage?.image_input_tokens || 0}</div>
-                            <div><span className="font-medium">{t('log.reasoning')}:</span> {log.usage?.reasoning_tokens || 0}</div>
-                            <div><span className="font-medium">{t('log.total')}:</span> {log.usage?.total_tokens || 0}</div>
-                            <div><span className="font-medium">{t('log.webSearchCount')}:</span> {log.usage?.web_search_count || 0}</div>
-                        </div>
-                    </div>
-
-                    {/* 时间信息 */}
-                    <div className="space-y-2">
-                        <h4 className="font-semibold text-sm">{t('log.timeInfo')}</h4>
-                        <div className="space-y-1 text-sm">
-                            <div><span className="font-medium">{t('log.created')}:</span> {log.created_at ? format(new Date(log.created_at), 'yyyy-MM-dd HH:mm:ss') : '-'}</div>
-                            <div><span className="font-medium">{t('log.request')}:</span> {log.request_at ? format(new Date(log.request_at), 'yyyy-MM-dd HH:mm:ss') : '-'}</div>
-                            {log.retry_at && <div><span className="font-medium">{t('log.retry')}:</span> {format(new Date(log.retry_at), 'yyyy-MM-dd HH:mm:ss')}</div>}
-                            <div><span className="font-medium">{t('log.retryTimes')}:</span> {log.retry_times || 0}</div>
-                            <div><span className="font-medium">{t('log.ttfb')}:</span> {log.ttfb_milliseconds || 0}ms</div>
-                        </div>
-                    </div>
-                </div>
-
-                <Separator />
-
-                {/* 请求和响应内容 */}
-                <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
-                    <div>
-                        <h4 className="font-semibold text-sm mb-2">{t('log.requestBody')}</h4>
-                        {log.request_detail?.request_body ? (
-                            <JsonViewer
-                                src={log.request_detail.request_body}
-                                collapsed={1}
-                                name="request"
-                            />
-                        ) : (
-                            <div className="text-sm text-muted-foreground p-2 border rounded">
-                                {t('log.noRequestBody')}
-                            </div>
-                        )}
-                        {log.request_detail?.request_body_truncated && (
-                            <div className="text-xs text-amber-600 mt-1">⚠️ {t('log.contentTruncated')}</div>
-                        )}
-                    </div>
-                    <div>
-                        <h4 className="font-semibold text-sm mb-2">{t('log.responseBody')}</h4>
-                        {log.request_detail?.response_body ? (
-                            <JsonViewer
-                                src={log.request_detail.response_body}
-                                collapsed={1}
-                                name="response"
-                            />
-                        ) : (
-                            <div className="text-sm text-muted-foreground p-2 border rounded">
-                                {t('log.noResponseBody')}
-                            </div>
-                        )}
-                        {log.request_detail?.response_body_truncated && (
-                            <div className="text-xs text-amber-600 mt-1">⚠️ {t('log.contentTruncated')}</div>
-                        )}
-                    </div>
-                </div>
-            </div>
-        )
-    }
-
     return (
         <div className="h-full flex flex-col">
             <div className="flex-1 min-h-0">
@@ -318,7 +232,7 @@ export function LogTable({
                                             {expandedRows.has(row.original.id) && (
                                                 <tr>
                                                     <td colSpan={columns.length} className="p-0">
-                                                        {renderExpandedContent(row.original)}
+                                                        <ExpandedLogContent log={row.original} />
                                                     </td>
                                                 </tr>
                                             )}

+ 24 - 0
web/src/feature/log/hooks.ts

@@ -15,6 +15,30 @@ export const useLogs = (filters?: LogFilters) => {
         retry: false,
     })
 
+    return {
+        ...query,
+    }
+}
+
+// 获取日志详情
+export const useLogDetail = (logId: number | null) => {
+    const query = useQuery({
+        queryKey: ['logDetail', logId],
+        queryFn: () => {
+            if (!logId) return null
+            return logApi.getLogDetail(logId)
+        },
+        // 仅在有logId时启用查询
+        enabled: !!logId,
+        // 禁用自动重新获取
+        refetchOnWindowFocus: false,
+        refetchOnMount: false,
+        refetchOnReconnect: false,
+        refetchInterval: false,
+        // 禁用重试
+        retry: false,
+    })
+
     return {
         ...query,
     }

+ 6 - 0
web/src/feature/model/components/ModelDialog.tsx

@@ -43,6 +43,12 @@ export function ModelDialog({
         ? {
             model: model.model,
             type: model.type,
+            rpm: model.rpm,
+            tpm: model.tpm,
+            retry_times: model.retry_times,
+            timeout: model.timeout,
+            max_error_rate: model.max_error_rate,
+            force_save_detail: model.force_save_detail,
             plugin: model.plugin
         }
         : {

+ 197 - 14
web/src/feature/model/components/ModelForm.tsx

@@ -24,7 +24,7 @@ import {
 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
 import { ChevronDown, ChevronUp, Plus, X } from 'lucide-react'
 import { modelCreateSchema } from '@/validation/model'
-import { useCreateModel } from '../hooks'
+import { useCreateModel, useUpdateModel } from '../hooks'
 import { useTranslation } from 'react-i18next'
 import { ModelCreateForm } from '@/validation/model'
 import { Plugin, EngineConfig } from '@/types/model'
@@ -40,6 +40,12 @@ interface ModelFormProps {
     defaultValues?: {
         model: string
         type: number
+        rpm?: number
+        tpm?: number
+        retry_times?: number
+        timeout?: number
+        max_error_rate?: number
+        force_save_detail?: boolean
         plugin?: Plugin
     }
 }
@@ -61,11 +67,23 @@ export function ModelForm({
     // API hooks
     const {
         createModel,
-        isLoading,
-        error,
-        clearError
+        isLoading: isCreating,
+        error: createError,
+        clearError: clearCreateError
     } = useCreateModel()
 
+    const {
+        updateModel,
+        isLoading: isUpdating,
+        error: updateError,
+        clearError: clearUpdateError
+    } = useUpdateModel()
+
+    // Combined loading and error states
+    const isLoading = isCreating || isUpdating
+    const error = mode === 'create' ? createError : updateError
+    const clearError = mode === 'create' ? clearCreateError : clearUpdateError
+
     // Form setup with simplified default values
     const form = useForm<ModelCreateForm>({
         resolver: zodResolver(modelCreateSchema),
@@ -73,6 +91,12 @@ export function ModelForm({
         defaultValues: {
             model: defaultValues.model || '',
             type: defaultValues.type || 1,
+            rpm: defaultValues.rpm,
+            tpm: defaultValues.tpm,
+            retry_times: defaultValues.retry_times,
+            timeout: defaultValues.timeout,
+            max_error_rate: defaultValues.max_error_rate,
+            force_save_detail: defaultValues.force_save_detail ?? false,
             plugin: {
                 cache: { enable: false, ...defaultValues.plugin?.cache },
                 "web-search": { enable: false, search_from: [], ...defaultValues.plugin?.["web-search"] },
@@ -248,20 +272,59 @@ export function ModelForm({
         }
 
         // Prepare data for API - 如果没有启用的插件,则不传递 plugin 字段
-        const formData: { model: string; type: number; plugin?: Plugin } = {
-            model: data.model,
+        const formData: { 
+            model?: string; 
+            type: number; 
+            rpm?: number;
+            tpm?: number;
+            retry_times?: number;
+            timeout?: number;
+            max_error_rate?: number;
+            force_save_detail?: boolean;
+            plugin?: Plugin 
+        } = {
             type: Number(data.type),
+            ...(data.rpm !== undefined && { rpm: Number(data.rpm) }),
+            ...(data.tpm !== undefined && { tpm: Number(data.tpm) }),
+            ...(data.retry_times !== undefined && { retry_times: Number(data.retry_times) }),
+            ...(data.timeout !== undefined && { timeout: Number(data.timeout) }),
+            ...(data.max_error_rate !== undefined && { max_error_rate: Number(data.max_error_rate) }),
+            ...(data.force_save_detail !== undefined && { force_save_detail: data.force_save_detail }),
             ...(Object.keys(pluginData).length > 0 && { plugin: pluginData as Plugin })
         }
 
-        createModel(formData, {
-            onSuccess: () => {
-                // Reset form
-                form.reset()
-                // Notify parent component
-                if (onSuccess) onSuccess()
-            }
-        })
+        if (mode === 'create') {
+            // For create mode, include the model name
+            createModel({
+                model: data.model,
+                type: Number(data.type),
+                ...(data.rpm !== undefined && { rpm: Number(data.rpm) }),
+                ...(data.tpm !== undefined && { tpm: Number(data.tpm) }),
+                ...(data.retry_times !== undefined && { retry_times: Number(data.retry_times) }),
+                ...(data.timeout !== undefined && { timeout: Number(data.timeout) }),
+                ...(data.max_error_rate !== undefined && { max_error_rate: Number(data.max_error_rate) }),
+                ...(data.force_save_detail !== undefined && { force_save_detail: data.force_save_detail }),
+                ...(Object.keys(pluginData).length > 0 && { plugin: pluginData as Plugin })
+            }, {
+                onSuccess: () => {
+                    // Reset form
+                    form.reset()
+                    // Notify parent component
+                    if (onSuccess) onSuccess()
+                }
+            })
+        } else {
+            // For update mode, use the model name as the identifier
+            updateModel({
+                model: data.model,
+                data: formData
+            }, {
+                onSuccess: () => {
+                    // Notify parent component
+                    if (onSuccess) onSuccess()
+                }
+            })
+        }
     }
 
     return (
@@ -342,6 +405,126 @@ export function ModelForm({
                         )}
                     />
 
+                    {/* RPM Field */}
+                    <FormField
+                        control={form.control}
+                        name="rpm"
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{t("model.dialog.rpm")}</FormLabel>
+                                <FormControl>
+                                    <Input
+                                        type="number"
+                                        placeholder={t("model.dialog.rpmPlaceholder")}
+                                        {...field}
+                                        onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+                                    />
+                                </FormControl>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+
+                    {/* TPM Field */}
+                    <FormField
+                        control={form.control}
+                        name="tpm"
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{t("model.dialog.tpm")}</FormLabel>
+                                <FormControl>
+                                    <Input
+                                        type="number"
+                                        placeholder={t("model.dialog.tpmPlaceholder")}
+                                        {...field}
+                                        onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+                                    />
+                                </FormControl>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+
+                    {/* Retry Times Field */}
+                    <FormField
+                        control={form.control}
+                        name="retry_times"
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{t("model.dialog.retryTimes")}</FormLabel>
+                                <FormControl>
+                                    <Input
+                                        type="number"
+                                        placeholder={t("model.dialog.retryTimesPlaceholder")}
+                                        {...field}
+                                        onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+                                    />
+                                </FormControl>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+
+                    {/* Timeout Field */}
+                    <FormField
+                        control={form.control}
+                        name="timeout"
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{t("model.dialog.timeout")}</FormLabel>
+                                <FormControl>
+                                    <Input
+                                        type="number"
+                                        placeholder={t("model.dialog.timeoutPlaceholder")}
+                                        {...field}
+                                        onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+                                    />
+                                </FormControl>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+
+                    {/* Max Error Rate Field */}
+                    <FormField
+                        control={form.control}
+                        name="max_error_rate"
+                        render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{t("model.dialog.maxErrorRate")}</FormLabel>
+                                <FormControl>
+                                    <Input
+                                        type="number"
+                                        placeholder={t("model.dialog.maxErrorRatePlaceholder")}
+                                        {...field}
+                                        min="0"
+                                        max="1"
+                                        step="0.01"
+                                        onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+                                    />
+                                </FormControl>
+                                <FormMessage />
+                            </FormItem>
+                        )}
+                    />
+
+                    {/* Force Save Detail Switch */}
+                    <FormField
+                        control={form.control}
+                        name="force_save_detail"
+                        render={({ field }) => (
+                            <FormItem className="flex flex-row items-center justify-between py-2">
+                                <FormLabel>{t("model.dialog.forceSaveDetail")}</FormLabel>
+                                <FormControl>
+                                    <Switch
+                                        checked={field.value}
+                                        onCheckedChange={field.onChange}
+                                    />
+                                </FormControl>
+                            </FormItem>
+                        )}
+                    />
+
                     {/* Plugin Configuration Section */}
                     <div className="space-y-6">
                         <div>

+ 377 - 277
web/src/feature/model/components/ModelTable.tsx

@@ -1,305 +1,405 @@
 // src/feature/model/components/ModelTable.tsx
-import { useState, useMemo } from 'react'
-import { useModels } from '../hooks'
-import { ModelConfig } from '@/types/model'
-import { Button } from '@/components/ui/button'
+import { useState, useMemo } from "react";
+import { useModels, useModelSets } from "../hooks";
+import { useChannelTypeMetas } from "@/feature/channel/hooks";
+import { ModelConfig } from "@/types/model";
+import { Button } from "@/components/ui/button";
 import {
-    MoreHorizontal, Plus, Trash2, RefreshCcw, Pencil, FileText,
-} from 'lucide-react'
+  MoreHorizontal,
+  Plus,
+  Trash2,
+  RefreshCcw,
+  Pencil,
+  FileText,
+} from "lucide-react";
 import {
-    DropdownMenu, DropdownMenuContent,
-    DropdownMenuItem, DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import { Card } from '@/components/ui/card'
-import { ModelDialog } from './ModelDialog'
-import { DeleteModelDialog } from './DeleteModelDialog'
-import { useTranslation } from 'react-i18next'
-import { DataTable } from '@/components/table/motion-data-table'
-import { ColumnDef } from '@tanstack/react-table'
-import { useReactTable, getCoreRowModel, getSortedRowModel } from '@tanstack/react-table'
-import { AdvancedErrorDisplay } from '@/components/common/error/errorDisplay'
-import { AnimatedButton } from '@/components/ui/animation/components/animated-button'
-import { AnimatedIcon } from '@/components/ui/animation/components/animated-icon'
-import ApiDocDrawer from './api-doc/ApiDoc'
-import { Badge } from '@/components/ui/badge'
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Card } from "@/components/ui/card";
+import { ModelDialog } from "./ModelDialog";
+import { DeleteModelDialog } from "./DeleteModelDialog";
+import { useTranslation } from "react-i18next";
+import { DataTable } from "@/components/table/motion-data-table";
+import { ColumnDef } from "@tanstack/react-table";
+import {
+  useReactTable,
+  getCoreRowModel,
+  getSortedRowModel,
+} from "@tanstack/react-table";
+import { AdvancedErrorDisplay } from "@/components/common/error/errorDisplay";
+import { AnimatedButton } from "@/components/ui/animation/components/animated-button";
+import { AnimatedIcon } from "@/components/ui/animation/components/animated-icon";
+import ApiDocDrawer from "./api-doc/ApiDoc";
+import { Badge } from "@/components/ui/badge";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
 
 export function ModelTable() {
-    const { t } = useTranslation()
+  const { t } = useTranslation();
 
-    // State management
-    const [modelDialogOpen, setModelDialogOpen] = useState(false)
-    const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
-    const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
-    const [dialogMode, setDialogMode] = useState<'create' | 'update'>('create')
-    const [selectedModel, setSelectedModel] = useState<ModelConfig | null>(null)
-    const [isRefreshAnimating, setIsRefreshAnimating] = useState(false)
+  // State management
+  const [modelDialogOpen, setModelDialogOpen] = useState(false);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
+  const [dialogMode, setDialogMode] = useState<"create" | "update">("create");
+  const [selectedModel, setSelectedModel] = useState<ModelConfig | null>(null);
+  const [isRefreshAnimating, setIsRefreshAnimating] = useState(false);
 
-    // API Doc drawer state
-    const [apiDocOpen, setApiDocOpen] = useState(false)
+  // API Doc drawer state
+  const [apiDocOpen, setApiDocOpen] = useState(false);
 
-    // Get models list
-    const {
-        data: models,
-        isLoading,
-        error,
-        isError,
-        refetch
-    } = useModels()
+  // Get models list
+  const { data: models, isLoading, error, isError, refetch } = useModels();
 
-    // Sort models by type for stable sorting
-    const sortedModels = useMemo(() => {
-        if (!models) return []
-        return [...models].sort((a, b) => {
-            if (a.type === b.type) {
-                // Secondary sort by model name for stability
-                return a.model.localeCompare(b.model)
-            }
-            return a.type - b.type
-        })
-    }, [models])
+  // Get model sets data
+  const { data: modelSets, isLoading: isLoadingModelSets } = useModelSets();
 
-    // Create table columns
-    const columns: ColumnDef<ModelConfig>[] = [
-        {
-            accessorKey: 'model',
-            header: () => <div className="font-medium py-3.5">{t("model.modelName")}</div>,
-            cell: ({ row }) => <div className="font-medium">{row.original.model}</div>,
-        },
-        {
-            accessorKey: 'type',
-            header: () => <div className="font-medium py-3.5">{t("model.modelType")}</div>,
-            cell: ({ row }) => (
-                <div className="font-medium">
-                    {/* @ts-expect-error 动态翻译键 */}
-                    {t(`modeType.${row.original.type}`)}
-                </div>
-            ),
-        },
-        {
-            accessorKey: 'plugin',
-            header: () => <div className="font-medium py-3.5">{t("model.pluginInfo")}</div>,
-            cell: ({ row }) => {
-                const plugin = row.original.plugin
-                if (!plugin) {
-                    return (
-                        <div className="text-muted-foreground text-sm">
-                            {t("model.noPluginConfigured")}
-                        </div>
-                    )
-                }
+  // Get channel type metadata
+  const { data: channelTypeMetas, isLoading: isLoadingTypeMetas } = useChannelTypeMetas();
 
-                const enabledPlugins = []
-                
-                if (plugin.cache?.enable) {
-                    enabledPlugins.push(t("model.cachePlugin"))
-                }
-                
-                if (plugin["web-search"]?.enable) {
-                    enabledPlugins.push(t("model.webSearchPlugin"))
-                }
-                
-                if (plugin["think-split"]?.enable) {
-                    enabledPlugins.push(t("model.thinkSplitPlugin"))
-                }
+  // Sort models by type for stable sorting
+  const sortedModels = useMemo(() => {
+    if (!models) return [];
+    return [...models].sort((a, b) => {
+      if (a.type === b.type) {
+        // Secondary sort by model name for stability
+        return a.model.localeCompare(b.model);
+      }
+      return a.type - b.type;
+    });
+  }, [models]);
 
-                if (enabledPlugins.length === 0) {
-                    return (
-                        <div className="text-muted-foreground text-sm">
-                            {t("model.noPluginConfigured")}
-                        </div>
-                    )
-                }
+  // Get channel type name by type ID
+  const getChannelTypeName = (typeId: number): string => {
+    if (!channelTypeMetas) return `Type: ${typeId}`;
+    
+    const typeKey = String(typeId);
+    return channelTypeMetas[typeKey]?.name || `Type: ${typeId}`;
+  };
 
-                return (
-                    <div className="flex flex-wrap gap-1">
-                        {enabledPlugins.map((pluginName) => (
-                            <Badge
-                                key={pluginName}
-                                variant="outline"
-                                className="text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800"
-                            >
-                                {pluginName}
-                            </Badge>
-                        ))}
-                    </div>
-                )
-            },
-        },
-        // {
-        //     accessorKey: 'owner',
-        //     header: () => <div className="font-medium py-3.5">{t("model.owner")}</div>,
-        //     cell: ({ row }) => <div>{row.original.owner}</div>,
-        // },
-        // {
-        //     accessorKey: 'rpm',
-        //     header: () => <div className="font-medium py-3.5">{t("model.rpm")}</div>,
-        //     cell: ({ row }) => <div>{row.original.rpm}</div>,
-        // },
-        {
-            id: 'actions',
-            cell: ({ row }) => (
-                <DropdownMenu>
-                    <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon">
-                            <MoreHorizontal className="h-4 w-4" />
-                        </Button>
-                    </DropdownMenuTrigger>
-                    <DropdownMenuContent align="end">
-                        <DropdownMenuItem
-                            onClick={() => openApiDoc(row.original)}
-                        >
-                            <FileText className="mr-2 h-4 w-4" />
-                            {t("model.apiDetails")}
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                            onClick={() => openUpdateDialog(row.original)}
-                        >
-                            <Pencil className="mr-2 h-4 w-4" />
-                            {t("model.edit")}
-                        </DropdownMenuItem>
-                        <DropdownMenuItem
-                            onClick={() => openDeleteDialog(row.original.model)}
+  // Create table columns
+  const columns: ColumnDef<ModelConfig>[] = [
+    {
+      accessorKey: "model",
+      header: () => (
+        <div className="font-medium py-3.5">{t("model.modelName")}</div>
+      ),
+      cell: ({ row }) => (
+        <div className="font-medium">{row.original.model}</div>
+      ),
+    },
+    {
+      accessorKey: "type",
+      header: () => (
+        <div className="font-medium py-3.5">{t("model.modelType")}</div>
+      ),
+      cell: ({ row }) => (
+        <div className="font-medium">
+          {/* @ts-expect-error 动态翻译键 */}
+          {t(`modeType.${row.original.type}`)}
+        </div>
+      ),
+    },
+    {
+      accessorKey: "sets",
+      header: () => (
+        <div className="font-medium py-3.5">{t("model.accessibleSets")}</div>
+      ),
+      cell: ({ row }) => {
+        const modelName = row.original.model;
+        const modelSetData = modelSets?.[modelName];
+
+        if (isLoadingModelSets || isLoadingTypeMetas) {
+          return (
+            <div className="text-muted-foreground text-sm">
+              {t("model.loading")}
+            </div>
+          );
+        }
+
+        if (!modelSetData || Object.keys(modelSetData).length === 0) {
+          return (
+            <div className="text-muted-foreground text-sm">
+              {t("model.noChannel")}
+            </div>
+          );
+        }
+
+        return (
+          <div className="flex flex-wrap gap-1">
+            {Object.entries(modelSetData).map(([setName, channels]) => (
+              <Popover key={setName}>
+                <PopoverTrigger asChild>
+                  <Badge
+                    variant="outline"
+                    className="text-xs bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
+                  >
+                    {setName}
+                  </Badge>
+                </PopoverTrigger>
+                <PopoverContent className="w-auto p-3" align="start">
+                  <div className="space-y-2">
+                    <h4 className="font-medium">
+                      {t("model.availableChannels")}
+                    </h4>
+                    <div className="flex flex-col gap-1">
+                      {channels.map((channel) => (
+                        <div
+                          key={channel.id}
+                          className="flex items-center gap-2"
                         >
-                            <Trash2 className="mr-2 h-4 w-4 text-red-600 dark:text-red-500" />
-                            {t("model.delete")}
-                        </DropdownMenuItem>
-                    </DropdownMenuContent>
-                </DropdownMenu>
-            ),
-        },
-    ]
+                          <Badge variant="secondary" className="text-xs">
+                            {channel.name}
+                          </Badge>
+                          <span className="text-xs text-muted-foreground">
+                            ID: {channel.id}, {getChannelTypeName(channel.type)}
+                          </span>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                </PopoverContent>
+              </Popover>
+            ))}
+          </div>
+        );
+      },
+    },
+    {
+      accessorKey: "plugin",
+      header: () => (
+        <div className="font-medium py-3.5">{t("model.pluginInfo")}</div>
+      ),
+      cell: ({ row }) => {
+        const plugin = row.original.plugin;
+        if (!plugin) {
+          return (
+            <div className="text-muted-foreground text-sm">
+              {t("model.noPluginConfigured")}
+            </div>
+          );
+        }
 
-    // Initialize table
-    const table = useReactTable({
-        data: sortedModels,
-        columns,
-        getCoreRowModel: getCoreRowModel(),
-        getSortedRowModel: getSortedRowModel(),
-        initialState: {
-            sorting: [
-                {
-                    id: 'type',
-                    desc: false,
-                },
-            ],
-        },
-    })
+        const enabledPlugins = [];
 
-    // Open create model dialog
-    const openCreateDialog = () => {
-        setDialogMode('create')
-        setSelectedModel(null)
-        setModelDialogOpen(true)
-    }
+        if (plugin.cache?.enable) {
+          enabledPlugins.push(t("model.cachePlugin"));
+        }
 
-    // Open update model dialog
-    const openUpdateDialog = (model: ModelConfig) => {
-        setDialogMode('update')
-        setSelectedModel(model)
-        setModelDialogOpen(true)
-    }
+        if (plugin["web-search"]?.enable) {
+          enabledPlugins.push(t("model.webSearchPlugin"));
+        }
 
-    // Open delete dialog
-    const openDeleteDialog = (id: string) => {
-        setSelectedModelId(id)
-        setDeleteDialogOpen(true)
-    }
+        if (plugin["think-split"]?.enable) {
+          enabledPlugins.push(t("model.thinkSplitPlugin"));
+        }
 
-    // Open API documentation drawer
-    const openApiDoc = (model: ModelConfig) => {
-        setSelectedModel(model)
-        setApiDocOpen(true)
-    }
+        if (enabledPlugins.length === 0) {
+          return (
+            <div className="text-muted-foreground text-sm">
+              {t("model.noPluginConfigured")}
+            </div>
+          );
+        }
 
-    // Refresh models
-    const refreshModels = () => {
-        setIsRefreshAnimating(true)
-        refetch()
+        return (
+          <div className="flex flex-wrap gap-1">
+            {enabledPlugins.map((pluginName) => (
+              <Badge
+                key={pluginName}
+                variant="outline"
+                className="text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800"
+              >
+                {pluginName}
+              </Badge>
+            ))}
+          </div>
+        );
+      },
+    },
+    // {
+    //     accessorKey: 'owner',
+    //     header: () => <div className="font-medium py-3.5">{t("model.owner")}</div>,
+    //     cell: ({ row }) => <div>{row.original.owner}</div>,
+    // },
+    // {
+    //     accessorKey: 'rpm',
+    //     header: () => <div className="font-medium py-3.5">{t("model.rpm")}</div>,
+    //     cell: ({ row }) => <div>{row.original.rpm}</div>,
+    // },
+    {
+      id: "actions",
+      cell: ({ row }) => (
+        <DropdownMenu>
+          <DropdownMenuTrigger asChild>
+            <Button variant="ghost" size="icon">
+              <MoreHorizontal className="h-4 w-4" />
+            </Button>
+          </DropdownMenuTrigger>
+          <DropdownMenuContent align="end">
+            <DropdownMenuItem onClick={() => openApiDoc(row.original)}>
+              <FileText className="mr-2 h-4 w-4" />
+              {t("model.apiDetails")}
+            </DropdownMenuItem>
+            <DropdownMenuItem onClick={() => openUpdateDialog(row.original)}>
+              <Pencil className="mr-2 h-4 w-4" />
+              {t("model.edit")}
+            </DropdownMenuItem>
+            <DropdownMenuItem
+              onClick={() => openDeleteDialog(row.original.model)}
+            >
+              <Trash2 className="mr-2 h-4 w-4 text-red-600 dark:text-red-500" />
+              {t("model.delete")}
+            </DropdownMenuItem>
+          </DropdownMenuContent>
+        </DropdownMenu>
+      ),
+    },
+  ];
 
-        // Stop animation after 1 second
-        setTimeout(() => {
-            setIsRefreshAnimating(false)
-        }, 1000)
-    }
+  // Initialize table
+  const table = useReactTable({
+    data: sortedModels,
+    columns,
+    getCoreRowModel: getCoreRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+    initialState: {
+      sorting: [
+        {
+          id: "type",
+          desc: false,
+        },
+      ],
+    },
+  });
 
-    return (
-        <>
-            <Card className="border-none shadow-none p-6 flex flex-col h-full">
-                {/* Title and action buttons */}
-                <div className="flex items-center justify-between mb-6">
-                    <h2 className="text-xl font-semibold text-primary">{t("model.management")}</h2>
-                    <div className="flex gap-2">
-                        <AnimatedButton >
-                            <Button
-                                variant="outline"
-                                size="sm"
-                                onClick={refreshModels}
-                                className="flex items-center gap-2 justify-center"
-                            >
-                                <AnimatedIcon animationVariant="continuous-spin" isAnimating={isRefreshAnimating} className="h-4 w-4">
-                                    <RefreshCcw className="h-4 w-4" />
-                                </AnimatedIcon>
-                                {t("model.refresh")}
-                            </Button>
-                        </AnimatedButton>
-                        <AnimatedButton >
-                            <Button
-                                size="sm"
-                                onClick={openCreateDialog}
-                                className="flex items-center gap-1"
-                            >
-                                <Plus className="h-4 w-4" />
-                                {t("model.add")}
-                            </Button>
-                        </AnimatedButton>
-                    </div>
-                </div>
+  // Open create model dialog
+  const openCreateDialog = () => {
+    setDialogMode("create");
+    setSelectedModel(null);
+    setModelDialogOpen(true);
+  };
 
-                {/* Table container */}
-                <div className="flex-1 overflow-hidden flex flex-col">
-                    <div className="overflow-auto h-full">
-                        {isError ? (
-                            <AdvancedErrorDisplay error={error} onRetry={refetch} />
-                        ) : (
-                            <DataTable
-                                table={table}
-                                columns={columns}
-                                isLoading={isLoading}
-                                loadingStyle="skeleton"
-                                fixedHeader={true}
-                                animatedRows={true}
-                                showScrollShadows={true}
-                            />
-                        )}
-                    </div>
-                </div>
-            </Card>
+  // Open update model dialog
+  const openUpdateDialog = (model: ModelConfig) => {
+    setDialogMode("update");
+    setSelectedModel(model);
+    setModelDialogOpen(true);
+  };
+
+  // Open delete dialog
+  const openDeleteDialog = (id: string) => {
+    setSelectedModelId(id);
+    setDeleteDialogOpen(true);
+  };
 
-            {/* Model Dialog */}
-            <ModelDialog
-                open={modelDialogOpen}
-                onOpenChange={setModelDialogOpen}
-                mode={dialogMode}
-                model={selectedModel}
-            />
+  // Open API documentation drawer
+  const openApiDoc = (model: ModelConfig) => {
+    setSelectedModel(model);
+    setApiDocOpen(true);
+  };
 
-            {/* Delete Model Dialog */}
-            <DeleteModelDialog
-                open={deleteDialogOpen}
-                onOpenChange={setDeleteDialogOpen}
-                modelId={selectedModelId}
-                onDeleted={() => setSelectedModelId(null)}
-            />
+  // Refresh models
+  const refreshModels = () => {
+    setIsRefreshAnimating(true);
+    refetch();
 
-            {/* API Documentation Drawer */}
+    // Stop animation after 1 second
+    setTimeout(() => {
+      setIsRefreshAnimating(false);
+    }, 1000);
+  };
 
-            {selectedModel && (
-                <ApiDocDrawer
-                    isOpen={apiDocOpen}
-                    onClose={() => setApiDocOpen(false)}
-                    modelConfig={selectedModel}
-                />
+  return (
+    <>
+      <Card className="border-none shadow-none p-6 flex flex-col h-full">
+        {/* Title and action buttons */}
+        <div className="flex items-center justify-between mb-6">
+          <h2 className="text-xl font-semibold text-primary">
+            {t("model.management")}
+          </h2>
+          <div className="flex gap-2">
+            <AnimatedButton>
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={refreshModels}
+                className="flex items-center gap-2 justify-center"
+              >
+                <AnimatedIcon
+                  animationVariant="continuous-spin"
+                  isAnimating={isRefreshAnimating}
+                  className="h-4 w-4"
+                >
+                  <RefreshCcw className="h-4 w-4" />
+                </AnimatedIcon>
+                {t("model.refresh")}
+              </Button>
+            </AnimatedButton>
+            <AnimatedButton>
+              <Button
+                size="sm"
+                onClick={openCreateDialog}
+                className="flex items-center gap-1"
+              >
+                <Plus className="h-4 w-4" />
+                {t("model.add")}
+              </Button>
+            </AnimatedButton>
+          </div>
+        </div>
+
+        {/* Table container */}
+        <div className="flex-1 overflow-hidden flex flex-col">
+          <div className="overflow-auto h-full">
+            {isError ? (
+              <AdvancedErrorDisplay error={error} onRetry={refetch} />
+            ) : (
+              <DataTable
+                table={table}
+                columns={columns}
+                isLoading={isLoading || isLoadingModelSets || isLoadingTypeMetas}
+                loadingStyle="skeleton"
+                fixedHeader={true}
+                animatedRows={true}
+                showScrollShadows={true}
+              />
             )}
-        </>
-    )
-}
+          </div>
+        </div>
+      </Card>
+
+      {/* Model Dialog */}
+      <ModelDialog
+        open={modelDialogOpen}
+        onOpenChange={setModelDialogOpen}
+        mode={dialogMode}
+        model={selectedModel}
+      />
+
+      {/* Delete Model Dialog */}
+      <DeleteModelDialog
+        open={deleteDialogOpen}
+        onOpenChange={setDeleteDialogOpen}
+        modelId={selectedModelId}
+        onDeleted={() => setSelectedModelId(null)}
+      />
+
+      {/* API Documentation Drawer */}
+
+      {selectedModel && (
+        <ApiDocDrawer
+          isOpen={apiDocOpen}
+          onClose={() => setApiDocOpen(false)}
+          modelConfig={selectedModel}
+        />
+      )}
+    </>
+  );
+}

+ 100 - 67
web/src/feature/model/hooks.ts

@@ -1,85 +1,118 @@
 // src/feature/model/hooks.ts
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { modelApi } from '@/api/model'
-import { useState } from 'react'
-import { ModelCreateRequest } from '@/types/model'
-import { toast } from 'sonner'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { modelApi } from "@/api/model";
+import { useState } from "react";
+import { ModelCreateRequest } from "@/types/model";
+import { toast } from "sonner";
+import { ApiError } from "@/api/index";
 
 // Get all models
 export const useModels = () => {
-    const query = useQuery({
-        queryKey: ['models'],
-        queryFn: modelApi.getModels,
-    })
+  return useQuery({
+    queryKey: ["models"],
+    queryFn: () => modelApi.getModels(),
+    staleTime: 1000 * 60 * 5, // 5 minutes
+  });
+};
 
-    return {
-        ...query,
-    }
-}
+// Get model sets data
+export const useModelSets = () => {
+  return useQuery({
+    queryKey: ["modelSets"],
+    queryFn: () => modelApi.getModelSets(),
+    staleTime: 1000 * 60 * 5, // 5 minutes
+  });
+};
 
 // Get a specific model
 export const useModel = (model: string) => {
-    const query = useQuery({
-        queryKey: ['model', model],
-        queryFn: () => modelApi.getModel(model),
-        enabled: !!model,
-    })
-
-    return {
-        ...query,
-    }
-}
+  return useQuery({
+    queryKey: ["model", model],
+    queryFn: () => modelApi.getModel(model),
+    enabled: !!model,
+    staleTime: 1000 * 60 * 5, // 5 minutes
+  });
+};
 
 // Create a new model
 export const useCreateModel = () => {
-    const queryClient = useQueryClient()
-    const [error, setError] = useState<ApiError | null>(null)
+  const queryClient = useQueryClient();
+  const [error, setError] = useState<ApiError | null>(null);
+
+  const mutation = useMutation({
+    mutationFn: (data: ModelCreateRequest) => {
+      return modelApi.createModel(data);
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["models"] });
+      setError(null);
+    },
+    onError: (err: ApiError) => {
+      setError(err);
+      toast.error(err.message);
+    },
+  });
+
+  return {
+    createModel: mutation.mutate,
+    isLoading: mutation.isPending,
+    error,
+    clearError: () => setError(null),
+  };
+};
+
+// Update an existing model
+export const useUpdateModel = () => {
+  const queryClient = useQueryClient();
+  const [error, setError] = useState<ApiError | null>(null);
 
-    const mutation = useMutation({
-        mutationFn: (data: ModelCreateRequest) => {
-            return modelApi.createModel(data)
-        },
-        onSuccess: () => {
-            queryClient.invalidateQueries({ queryKey: ['models'] })
-            setError(null)
-        },
-        onError: (err: ApiError) => {
-            setError(err)
-            toast.error(err.message)
-        },
-    })
+  const mutation = useMutation({
+    mutationFn: ({
+      model,
+      data,
+    }: {
+      model: string;
+      data: Omit<ModelCreateRequest, "model">;
+    }) => {
+      return modelApi.updateModel(model, data);
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["models"] });
+      setError(null);
+    },
+    onError: (err: ApiError) => {
+      setError(err);
+      toast.error(err.message);
+    },
+  });
 
-    return {
-        createModel: mutation.mutate,
-        isLoading: mutation.isPending,
-        error,
-        clearError: () => setError(null),
-    }
-}
+  return {
+    updateModel: mutation.mutate,
+    isLoading: mutation.isPending,
+    error,
+    clearError: () => setError(null),
+  };
+};
 
 // Delete a model
 export const useDeleteModel = () => {
-    const queryClient = useQueryClient()
-    const [error, setError] = useState<ApiError | null>(null)
+  const queryClient = useQueryClient();
 
-    const mutation = useMutation({
-        mutationFn: (model: string) => {
-            return modelApi.deleteModel(model)
-        },
-        onSuccess: () => {
-            queryClient.invalidateQueries({ queryKey: ['models'] })
-            setError(null)
-        },
-        onError: (err: ApiError) => {
-            setError(err)
-            toast.error(err.message)
-        },
-    })
+  const mutation = useMutation({
+    mutationFn: (model: string) => {
+      return modelApi.deleteModel(model);
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["models"] });
+      toast.success("Model deleted successfully");
+    },
+    onError: (err: ApiError) => {
+      toast.error(err.message);
+    },
+  });
 
-    return {
-        deleteModel: mutation.mutate,
-        isLoading: mutation.isPending,
-        error,
-        clearError: () => setError(null),
-    }
-}
+  return {
+    deleteModel: mutation.mutate,
+    isLoading: mutation.isPending,
+  };
+};

+ 217 - 0
web/src/pages/mcp/components/EmbedMCP.tsx

@@ -0,0 +1,217 @@
+import { useState, useEffect } from "react";
+import { useToast } from "@/components/ui/use-toast";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { EmbedMCP, getEmbedMCPs, saveEmbedMCP } from "@/api/mcp";
+import { useTranslation } from "react-i18next";
+
+const EmbedMCPComponent = () => {
+  const [embedMCPs, setEmbedMCPs] = useState<EmbedMCP[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [searchTerm, setSearchTerm] = useState("");
+  const [configValues, setConfigValues] = useState<
+    Record<string, Record<string, string>>
+  >({});
+  const [savingId, setSavingId] = useState<string | null>(null);
+  const { toast } = useToast();
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    fetchEmbedMCPs();
+  }, []);
+
+  const fetchEmbedMCPs = async () => {
+    try {
+      setLoading(true);
+      const data = await getEmbedMCPs();
+      setEmbedMCPs(data);
+
+      // Initialize config values
+      const initialConfigValues: Record<string, Record<string, string>> = {};
+      data.forEach((mcp) => {
+        initialConfigValues[mcp.id] = {};
+        Object.entries(mcp.config_templates).forEach(([key, template]) => {
+          initialConfigValues[mcp.id][key] = template.example || "";
+        });
+      });
+      setConfigValues(initialConfigValues);
+    } catch (err) {
+      toast({
+        title: t("error.loading"),
+        description: t("mcp.embed.noEmbeddedServers"),
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleInputChange = (
+    mcpId: string,
+    configKey: string,
+    value: string
+  ) => {
+    setConfigValues((prev) => ({
+      ...prev,
+      [mcpId]: {
+        ...prev[mcpId],
+        [configKey]: value,
+      },
+    }));
+  };
+
+  const handleStatusToggle = (mcpId: string, enabled: boolean) => {
+    setEmbedMCPs((prev) =>
+      prev.map((mcp) =>
+        mcp.id === mcpId ? { ...mcp, enabled: !enabled } : mcp
+      )
+    );
+  };
+
+  const handleSave = async (mcp: EmbedMCP) => {
+    try {
+      setSavingId(mcp.id);
+      await saveEmbedMCP({
+        id: mcp.id,
+        enabled: mcp.enabled,
+        init_config: configValues[mcp.id] || {},
+      });
+      toast({
+        title: t("common.success"),
+        description: `${mcp.name} ${t("mcp.embed.configSaved")}`,
+      });
+    } catch (err) {
+      toast({
+        title: t("error.server"),
+        description: t("mcp.embed.saveError"),
+        variant: "destructive",
+      });
+    } finally {
+      setSavingId(null);
+    }
+  };
+
+  const filteredMCPs = embedMCPs.filter(
+    (mcp) =>
+      mcp.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.tags?.some((tag) =>
+        tag.toLowerCase().includes(searchTerm.toLowerCase())
+      )
+  );
+
+  if (loading) {
+    return <div className="flex justify-center p-8">{t("common.loading")}</div>;
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <Input
+          className="max-w-xs"
+          placeholder={t("mcp.list.search")}
+          value={searchTerm}
+          onChange={(e) => setSearchTerm(e.target.value)}
+        />
+        <Button onClick={fetchEmbedMCPs}>{t("mcp.refresh")}</Button>
+      </div>
+
+      {filteredMCPs.length === 0 ? (
+        <div className="text-center p-8">
+          {t("mcp.embed.noEmbeddedServers")}
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+          {filteredMCPs.map((mcp) => (
+            <Card key={mcp.id} className="overflow-hidden">
+              <CardHeader>
+                <div className="flex justify-between items-start">
+                  <div>
+                    <CardTitle>{mcp.name}</CardTitle>
+                    <div className="text-sm text-muted-foreground">
+                      {mcp.id}
+                    </div>
+                  </div>
+                  <div className="flex items-center space-x-2">
+                    <Label htmlFor={`enable-${mcp.id}`} className="text-sm">
+                      {mcp.enabled ? t("mcp.enabled") : t("mcp.disabled")}
+                    </Label>
+                    <Switch
+                      id={`enable-${mcp.id}`}
+                      checked={mcp.enabled}
+                      onCheckedChange={() =>
+                        handleStatusToggle(mcp.id, mcp.enabled)
+                      }
+                    />
+                  </div>
+                </div>
+                <div className="flex flex-wrap gap-1 mt-1">
+                  {mcp.tags?.map((tag) => (
+                    <Badge key={tag} variant="outline">
+                      {tag}
+                    </Badge>
+                  ))}
+                </div>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                {mcp.readme && (
+                  <div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto text-sm">
+                    <pre className="whitespace-pre-wrap">{mcp.readme}</pre>
+                  </div>
+                )}
+
+                <div className="space-y-3">
+                  <h3 className="text-sm font-medium">
+                    {t("mcp.config.title")}
+                  </h3>
+                  {Object.entries(mcp.config_templates).map(
+                    ([key, template]) => (
+                      <div key={key} className="space-y-1">
+                        <Label
+                          htmlFor={`${mcp.id}-${key}`}
+                          className="flex items-center"
+                        >
+                          {template.name}
+                          {template.required && (
+                            <span className="text-red-500 ml-1">*</span>
+                          )}
+                        </Label>
+                        <Input
+                          id={`${mcp.id}-${key}`}
+                          placeholder={template.example}
+                          value={configValues[mcp.id]?.[key] || ""}
+                          onChange={(e) =>
+                            handleInputChange(mcp.id, key, e.target.value)
+                          }
+                        />
+                        <p className="text-xs text-muted-foreground">
+                          {template.description}
+                        </p>
+                      </div>
+                    )
+                  )}
+                </div>
+
+                <Button
+                  className="w-full"
+                  onClick={() => handleSave(mcp)}
+                  disabled={savingId === mcp.id}
+                >
+                  {savingId === mcp.id
+                    ? t("model.dialog.submitting")
+                    : t("mcp.config.submit")}
+                </Button>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default EmbedMCPComponent;

+ 969 - 0
web/src/pages/mcp/components/MCPConfig.tsx

@@ -0,0 +1,969 @@
+import { useState, useEffect } from "react";
+import { useToast } from "@/components/ui/use-toast";
+import {
+  Card,
+  CardContent,
+  CardHeader,
+  CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import {
+  AlertCircle,
+  Trash2,
+  Plus,
+  Settings,
+  KeyRound,
+  ShieldAlert,
+} from "lucide-react";
+import {
+  PublicMCP,
+  createMCP,
+  updateMCP,
+  getAllMCPs,
+  deleteMCP,
+  updateMCPStatus,
+  PublicMCPProxyConfig,
+  MCPOpenAPIConfig,
+} from "@/api/mcp";
+import {
+  Dialog,
+  DialogContent,
+  DialogTrigger,
+  DialogTitle,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+} from "@/components/ui/dialog";
+import { CopyButton } from "@/components/common/CopyButton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import ProxyConfig from "./config/ProxyConfig";
+import OpenAPIConfig from "./config/OpenAPIConfig";
+
+const initialMCP: Omit<PublicMCP, "created_at" | "update_at" | "endpoints"> = {
+  id: "",
+  name: "",
+  status: 1, // Enabled by default
+  type: "mcp_proxy_sse", // Default type
+  readme: "",
+  tags: [],
+  logo_url: "",
+};
+
+const MCPConfig = () => {
+  const [mcps, setMCPs] = useState<PublicMCP[]>([]);
+  const [newMCP, setNewMCP] =
+    useState<Omit<PublicMCP, "created_at" | "update_at" | "endpoints">>(
+      initialMCP
+    );
+  const [editMCP, setEditMCP] =
+    useState<Omit<PublicMCP, "created_at" | "update_at" | "endpoints">>(
+      initialMCP
+    );
+  const [tagInput, setTagInput] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [searchTerm, setSearchTerm] = useState("");
+  const [showCreateForm, setShowCreateForm] = useState(false);
+  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+  const [mcpToDelete, setMcpToDelete] = useState<PublicMCP | null>(null);
+  const [authMethod, setAuthMethod] = useState<"query" | "header">("query");
+  const { toast } = useToast();
+
+  useEffect(() => {
+    fetchMCPs();
+  }, []);
+
+  const fetchMCPs = async () => {
+    try {
+      setLoading(true);
+      const data = await getAllMCPs();
+      setMCPs(data);
+    } catch (err) {
+      toast({
+        title: "Error",
+        description: "Failed to fetch MCPs",
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleCreateChange = (
+    field: keyof typeof newMCP,
+    value: string | number | string[] | PublicMCPProxyConfig | MCPOpenAPIConfig
+  ) => {
+    setNewMCP((prev) => ({ ...prev, [field]: value }));
+  };
+
+  const handleEditChange = (
+    field: keyof typeof editMCP,
+    value: string | number | string[] | PublicMCPProxyConfig | MCPOpenAPIConfig
+  ) => {
+    setEditMCP((prev) => ({ ...prev, [field]: value }));
+  };
+
+  const handleAddTag = () => {
+    if (tagInput.trim()) {
+      if (isEditing) {
+        setEditMCP((prev) => ({
+          ...prev,
+          tags: [...(prev.tags || []), tagInput.trim()],
+        }));
+      } else {
+        setNewMCP((prev) => ({
+          ...prev,
+          tags: [...(prev.tags || []), tagInput.trim()],
+        }));
+      }
+      setTagInput("");
+    }
+  };
+
+  const handleRemoveTag = (tag: string) => {
+    if (isEditing) {
+      setEditMCP((prev) => ({
+        ...prev,
+        tags: (prev.tags || []).filter((t) => t !== tag),
+      }));
+    } else {
+      setNewMCP((prev) => ({
+        ...prev,
+        tags: (prev.tags || []).filter((t) => t !== tag),
+      }));
+    }
+  };
+
+  const handleSubmit = async () => {
+    try {
+      setLoading(true);
+      const mcpData = isEditing ? editMCP : newMCP;
+
+      // Basic validation
+      if (!mcpData.id.trim() || !mcpData.name.trim() || !mcpData.type) {
+        toast({
+          title: "Validation Error",
+          description: "ID, name, and type are required fields",
+          variant: "destructive",
+        });
+        return;
+      }
+
+      // Type-specific validation
+      if (
+        mcpData.type === "mcp_proxy_sse" ||
+        mcpData.type === "mcp_proxy_streamable"
+      ) {
+        if (!mcpData.proxy_config?.url) {
+          toast({
+            title: "Validation Error",
+            description: "Backend URL is required for proxy MCP",
+            variant: "destructive",
+          });
+          return;
+        }
+      } else if (mcpData.type === "mcp_openapi") {
+        if (
+          !mcpData.openapi_config?.openapi_spec &&
+          !mcpData.openapi_config?.openapi_content
+        ) {
+          toast({
+            title: "Validation Error",
+            description: "OpenAPI specification URL or content is required",
+            variant: "destructive",
+          });
+          return;
+        }
+      }
+
+      // Create or update MCP
+      if (isEditing) {
+        await updateMCP(mcpData.id, mcpData as PublicMCP);
+        toast({
+          title: "Success",
+          description: "MCP updated successfully",
+        });
+      } else {
+        await createMCP(mcpData as PublicMCP);
+        toast({
+          title: "Success",
+          description: "MCP created successfully",
+        });
+      }
+
+      // Reset form and refresh the list
+      setNewMCP(initialMCP);
+      setEditMCP(initialMCP);
+      setIsEditing(false);
+      setShowCreateForm(false);
+      fetchMCPs();
+    } catch (err) {
+      toast({
+        title: "Error",
+        description: "Failed to save MCP configuration",
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleReset = () => {
+    if (isEditing) {
+      setEditMCP(initialMCP);
+    } else {
+      setNewMCP(initialMCP);
+    }
+    setTagInput("");
+  };
+
+  const handleStatusToggle = async (id: string, currentStatus: number) => {
+    // 检查是否为mcp_embed类型
+    const mcpToToggle = mcps.find((mcp) => mcp.id === id);
+
+    if (mcpToToggle?.type === "mcp_embed") {
+      toast({
+        title: "Not Allowed",
+        description:
+          "Embedded MCP servers status cannot be changed from this interface.",
+        variant: "destructive",
+      });
+      return;
+    }
+
+    const newStatus = currentStatus === 1 ? 2 : 1; // Toggle between enabled (1) and disabled (2)
+
+    try {
+      await updateMCPStatus(id, newStatus);
+      setMCPs(
+        mcps.map((mcp) => (mcp.id === id ? { ...mcp, status: newStatus } : mcp))
+      );
+      toast({
+        title: "Success",
+        description: `MCP ${
+          newStatus === 1 ? "enabled" : "disabled"
+        } successfully`,
+      });
+    } catch (err) {
+      toast({
+        title: "Error",
+        description: "Failed to update MCP status",
+        variant: "destructive",
+      });
+    }
+  };
+
+  const handleConfirmDelete = async () => {
+    if (!mcpToDelete) return;
+
+    try {
+      await deleteMCP(mcpToDelete.id);
+      setMCPs(mcps.filter((mcp) => mcp.id !== mcpToDelete.id));
+      toast({
+        title: "Success",
+        description: "MCP deleted successfully",
+      });
+      setDeleteConfirmOpen(false);
+      setMcpToDelete(null);
+    } catch (err) {
+      toast({
+        title: "Error",
+        description: "Failed to delete MCP",
+        variant: "destructive",
+      });
+    }
+  };
+
+  const handleDeleteClick = (mcp: PublicMCP) => {
+    // 检查是否为mcp_embed类型
+    if (mcp.type === "mcp_embed") {
+      toast({
+        title: "Not Allowed",
+        description:
+          "Embedded MCP servers cannot be deleted from this interface.",
+        variant: "destructive",
+      });
+      return;
+    }
+
+    setMcpToDelete(mcp);
+    setDeleteConfirmOpen(true);
+  };
+
+  const handleEdit = (mcpToEdit: PublicMCP) => {
+    // 如果是mcp_embed类型,显示警告并阻止编辑
+    if (mcpToEdit.type === "mcp_embed") {
+      toast({
+        title: "Not Allowed",
+        description: "Embedded MCP servers cannot be edited.",
+        variant: "destructive",
+      });
+      return;
+    }
+
+    // 提取需要的字段
+    const {
+      id,
+      name,
+      status,
+      type,
+      readme,
+      tags,
+      logo_url,
+      proxy_config,
+      openapi_config,
+      embed_config,
+    } = mcpToEdit;
+    setEditMCP({
+      id,
+      name,
+      status,
+      type,
+      readme,
+      tags,
+      logo_url,
+      proxy_config,
+      openapi_config,
+      embed_config,
+    });
+    setIsEditing(true);
+    setShowCreateForm(true);
+  };
+
+  const handleOpenCreateForm = () => {
+    setNewMCP(initialMCP); // 确保创建表单始终是空的
+    setIsEditing(false);
+    setShowCreateForm(true);
+  };
+
+  const handleTypeChange = (type: string) => {
+    if (isEditing) {
+      // 当编辑已有MCP时,只更新类型,不重置配置
+      setEditMCP((prev) => ({ ...prev, type }));
+    } else {
+      // 当创建新MCP时,根据类型初始化相应配置
+      let updatedMCP = { ...newMCP, type };
+
+      if (type === "mcp_proxy_sse" || type === "mcp_proxy_streamable") {
+        if (!updatedMCP.proxy_config) {
+          updatedMCP.proxy_config = {
+            url: "",
+            headers: {},
+            querys: {},
+            reusing_params: {},
+          };
+        }
+      } else if (type === "mcp_openapi") {
+        if (!updatedMCP.openapi_config) {
+          updatedMCP.openapi_config = {
+            openapi_spec: "",
+            v2: false,
+          };
+        }
+      }
+
+      setNewMCP(updatedMCP);
+    }
+  };
+
+  const filteredMCPs = mcps.filter(
+    (mcp) =>
+      mcp.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.tags?.some((tag) =>
+        tag.toLowerCase().includes(searchTerm.toLowerCase())
+      )
+  );
+
+  // 获取当前正在使用的MCP数据(编辑时使用editMCP,创建时使用newMCP)
+  const currentMCP = isEditing ? editMCP : newMCP;
+
+  // 根据当前MCP类型决定是否显示类型特定的配置
+  const showProxyConfig =
+    currentMCP.type === "mcp_proxy_sse" ||
+    currentMCP.type === "mcp_proxy_streamable";
+  const showOpenAPIConfig = currentMCP.type === "mcp_openapi";
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <h2 className="text-2xl font-bold">MCP Configuration</h2>
+        <Dialog
+          open={showCreateForm}
+          onOpenChange={(open) => {
+            if (open) {
+              // 如果是打开对话框,不做处理,因为handleOpenCreateForm会处理
+            } else {
+              // 如果是关闭对话框,重置状态
+              setShowCreateForm(false);
+            }
+          }}
+        >
+          <DialogTrigger asChild>
+            <Button onClick={handleOpenCreateForm}>
+              <Plus className="mr-2 h-4 w-4" />
+              Create New MCP
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+            <Card className="border-0 shadow-none">
+              <CardHeader>
+                <CardTitle>
+                  {isEditing ? "Edit MCP" : "Create New MCP"}
+                </CardTitle>
+              </CardHeader>
+              <CardContent className="space-y-6">
+                <div className="space-y-6">
+                  <h3 className="text-lg font-medium">Basic Information</h3>
+                  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                    <div className="space-y-2">
+                      <Label htmlFor="id">
+                        ID <span className="text-red-500">*</span>
+                      </Label>
+                      <Input
+                        id="id"
+                        value={currentMCP.id}
+                        onChange={(e) =>
+                          isEditing
+                            ? handleEditChange("id", e.target.value)
+                            : handleCreateChange("id", e.target.value)
+                        }
+                        placeholder="e.g., my-mcp-server"
+                        disabled={isEditing}
+                      />
+                      <p className="text-xs text-muted-foreground">
+                        Unique identifier for the MCP, alphanumeric with dashes
+                        only
+                      </p>
+                    </div>
+
+                    <div className="space-y-2">
+                      <Label htmlFor="name">
+                        Name <span className="text-red-500">*</span>
+                      </Label>
+                      <Input
+                        id="name"
+                        value={currentMCP.name}
+                        onChange={(e) =>
+                          isEditing
+                            ? handleEditChange("name", e.target.value)
+                            : handleCreateChange("name", e.target.value)
+                        }
+                        placeholder="e.g., My MCP Server"
+                      />
+                    </div>
+
+                    <div className="space-y-2">
+                      <Label htmlFor="type">
+                        Type <span className="text-red-500">*</span>
+                      </Label>
+                      <Select
+                        value={currentMCP.type}
+                        onValueChange={handleTypeChange}
+                      >
+                        <SelectTrigger>
+                          <SelectValue placeholder="Select MCP type" />
+                        </SelectTrigger>
+                        <SelectContent>
+                          <SelectItem value="mcp_proxy_sse">
+                            MCP Proxy SSE
+                          </SelectItem>
+                          <SelectItem value="mcp_proxy_streamable">
+                            MCP Proxy Streamable
+                          </SelectItem>
+                          <SelectItem value="mcp_openapi">
+                            MCP OpenAPI
+                          </SelectItem>
+                          <SelectItem value="mcp_docs">
+                            MCP Documentation
+                          </SelectItem>
+                        </SelectContent>
+                      </Select>
+                    </div>
+
+                    <div className="space-y-2">
+                      <Label htmlFor="logo_url">Logo URL</Label>
+                      <Input
+                        id="logo_url"
+                        value={currentMCP.logo_url}
+                        onChange={(e) =>
+                          isEditing
+                            ? handleEditChange("logo_url", e.target.value)
+                            : handleCreateChange("logo_url", e.target.value)
+                        }
+                        placeholder="https://example.com/logo.png"
+                      />
+                    </div>
+                  </div>
+
+                  <div className="space-y-2">
+                    <Label htmlFor="tags">Tags</Label>
+                    <div className="flex gap-2">
+                      <Input
+                        id="tags"
+                        value={tagInput}
+                        onChange={(e) => setTagInput(e.target.value)}
+                        placeholder="Add a tag"
+                        onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) =>
+                          e.key === "Enter" &&
+                          (e.preventDefault(), handleAddTag())
+                        }
+                      />
+                      <Button type="button" onClick={handleAddTag}>
+                        Add
+                      </Button>
+                    </div>
+                    {currentMCP.tags && currentMCP.tags.length > 0 && (
+                      <div className="flex flex-wrap gap-2 mt-2">
+                        {currentMCP.tags.map((tag) => (
+                          <div
+                            key={tag}
+                            className="bg-muted text-muted-foreground rounded-md px-2 py-1 text-sm flex items-center"
+                          >
+                            {tag}
+                            <button
+                              type="button"
+                              className="ml-2 text-red-500 hover:text-red-700"
+                              onClick={() => handleRemoveTag(tag)}
+                            >
+                              ×
+                            </button>
+                          </div>
+                        ))}
+                      </div>
+                    )}
+                  </div>
+
+                  <div className="space-y-2">
+                    <Label htmlFor="readme">Readme</Label>
+                    <Textarea
+                      id="readme"
+                      value={currentMCP.readme}
+                      onChange={(e) =>
+                        isEditing
+                          ? handleEditChange("readme", e.target.value)
+                          : handleCreateChange("readme", e.target.value)
+                      }
+                      placeholder="Markdown supported"
+                      className="min-h-[200px]"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Provide documentation for using this MCP
+                    </p>
+                  </div>
+                </div>
+
+                {showProxyConfig && (
+                  <div className="space-y-6 border-t pt-6">
+                    <h3 className="text-lg font-medium">Proxy Configuration</h3>
+                    <ProxyConfig
+                      config={currentMCP.proxy_config}
+                      onChange={(config) =>
+                        isEditing
+                          ? handleEditChange("proxy_config", config)
+                          : handleCreateChange("proxy_config", config)
+                      }
+                    />
+                  </div>
+                )}
+
+                {showOpenAPIConfig && (
+                  <div className="space-y-6 border-t pt-6">
+                    <h3 className="text-lg font-medium">
+                      OpenAPI Configuration
+                    </h3>
+                    <OpenAPIConfig
+                      config={currentMCP.openapi_config}
+                      onChange={(config) =>
+                        isEditing
+                          ? handleEditChange("openapi_config", config)
+                          : handleCreateChange("openapi_config", config)
+                      }
+                    />
+                  </div>
+                )}
+
+                <div className="flex justify-end space-x-2 pt-4 border-t">
+                  <Button variant="outline" onClick={handleReset}>
+                    Reset
+                  </Button>
+                  <Button onClick={handleSubmit} disabled={loading}>
+                    {loading
+                      ? "Saving..."
+                      : isEditing
+                      ? "Update MCP"
+                      : "Create MCP"}
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+          </DialogContent>
+        </Dialog>
+      </div>
+
+      {/* 删除确认对话框 */}
+      <Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="flex items-center">
+              <AlertCircle className="h-5 w-5 text-red-500 mr-2" />
+              Delete MCP
+            </DialogTitle>
+            <DialogDescription>
+              Are you sure you want to delete the MCP "{mcpToDelete?.name}"?
+              This action cannot be undone.
+            </DialogDescription>
+          </DialogHeader>
+          <div className="p-4 bg-muted rounded-md mb-4">
+            <div className="font-medium">{mcpToDelete?.name}</div>
+            <div className="text-sm text-muted-foreground">
+              ID: {mcpToDelete?.id}
+            </div>
+            <div className="text-sm text-muted-foreground">
+              Type: {mcpToDelete?.type}
+            </div>
+          </div>
+          <DialogFooter className="sm:justify-end">
+            <Button
+              type="button"
+              variant="outline"
+              onClick={() => setDeleteConfirmOpen(false)}
+            >
+              Cancel
+            </Button>
+            <Button
+              type="button"
+              variant="destructive"
+              onClick={handleConfirmDelete}
+            >
+              <Trash2 className="h-4 w-4 mr-2" />
+              Delete
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      <Tabs defaultValue="all">
+        <TabsList>
+          <TabsTrigger value="all">All MCPs</TabsTrigger>
+          <TabsTrigger value="enabled">Enabled</TabsTrigger>
+          <TabsTrigger value="disabled">Disabled</TabsTrigger>
+        </TabsList>
+
+        <div className="mt-4 flex justify-between items-center">
+          <Input
+            className="max-w-xs"
+            placeholder="Search by name, ID, or tag..."
+            value={searchTerm}
+            onChange={(e) => setSearchTerm(e.target.value)}
+          />
+          <Button onClick={fetchMCPs}>Refresh</Button>
+        </div>
+
+        <TabsContent value="all" className="mt-4">
+          {renderMCPList(filteredMCPs)}
+        </TabsContent>
+
+        <TabsContent value="enabled" className="mt-4">
+          {renderMCPList(filteredMCPs.filter((mcp) => mcp.status === 1))}
+        </TabsContent>
+
+        <TabsContent value="disabled" className="mt-4">
+          {renderMCPList(filteredMCPs.filter((mcp) => mcp.status === 2))}
+        </TabsContent>
+      </Tabs>
+    </div>
+  );
+
+  function renderMCPList(mcpList: PublicMCP[]) {
+    if (loading) {
+      return <div className="flex justify-center p-8">Loading MCPs...</div>;
+    }
+
+    if (mcpList.length === 0) {
+      return <div className="text-center p-8">No MCPs found</div>;
+    }
+
+    // 获取当前协议
+    const protocol = window.location.protocol;
+
+    // 格式化URL,确保带有正确的协议前缀
+    const formatEndpointUrl = (host: string, path: string) => {
+      // 如果host已经包含协议,直接返回完整URL
+      if (host.startsWith("http://") || host.startsWith("https://")) {
+        return `${host}${path}`;
+      }
+
+      // 否则,根据当前页面协议添加协议前缀
+      return `${protocol}//${host}${path}`;
+    };
+
+    // 生成带有认证的URL
+    const getAuthenticatedUrl = (url: string) => {
+      if (authMethod === "query") {
+        return `${url}${url.includes("?") ? "&" : "?"}key=your-token`;
+      }
+      return url;
+    };
+
+    // 获取MCP类型的显示信息
+    const getTypeInfo = (type: string) => {
+      switch (type) {
+        case "mcp_proxy_sse":
+          return {
+            label: "Proxy SSE",
+            color:
+              "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
+          };
+        case "mcp_proxy_streamable":
+          return {
+            label: "Proxy Streamable",
+            color:
+              "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
+          };
+        case "mcp_openapi":
+          return {
+            label: "OpenAPI",
+            color:
+              "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
+          };
+        case "mcp_docs":
+          return {
+            label: "Docs",
+            color:
+              "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
+          };
+        case "mcp_embed":
+          return {
+            label: "Embed",
+            color:
+              "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
+          };
+        default:
+          return {
+            label: type,
+            color:
+              "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
+          };
+      }
+    };
+
+    return (
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+        {mcpList.map((mcp) => (
+          <Card key={mcp.id} className="overflow-hidden">
+            <CardHeader>
+              <div className="flex justify-between items-start">
+                <div>
+                  <CardTitle className="flex items-center">
+                    {mcp.name}
+                    <Badge
+                      className="ml-2"
+                      variant={mcp.status === 1 ? "default" : "secondary"}
+                    >
+                      {mcp.status === 1 ? "Enabled" : "Disabled"}
+                    </Badge>
+                  </CardTitle>
+                  <div className="text-sm text-muted-foreground">{mcp.id}</div>
+                  <div className="mt-1">
+                    <span
+                      className={`text-xs px-2 py-1 rounded-full ${
+                        getTypeInfo(mcp.type).color
+                      }`}
+                    >
+                      {getTypeInfo(mcp.type).label}
+                    </span>
+                  </div>
+                </div>
+                <div className="flex space-x-2">
+                  {mcp.type !== "mcp_embed" && (
+                    <>
+                      <Button
+                        size="sm"
+                        variant="outline"
+                        onClick={() => handleEdit(mcp)}
+                      >
+                        <Settings className="h-4 w-4" />
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant="destructive"
+                        onClick={() => handleDeleteClick(mcp)}
+                      >
+                        Delete
+                      </Button>
+                      <Button
+                        size="sm"
+                        variant={mcp.status === 1 ? "destructive" : "default"}
+                        onClick={() => handleStatusToggle(mcp.id, mcp.status)}
+                      >
+                        {mcp.status === 1 ? "Disable" : "Enable"}
+                      </Button>
+                    </>
+                  )}
+                </div>
+              </div>
+              <div className="flex flex-wrap gap-1 mt-1">
+                {mcp.tags?.map((tag) => (
+                  <Badge key={tag} variant="outline">
+                    {tag}
+                  </Badge>
+                ))}
+              </div>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              {(mcp.endpoints?.sse || mcp.endpoints?.streamable_http) && (
+                <div>
+                  <div className="flex items-center justify-between mb-2">
+                    <div className="font-medium">Endpoints:</div>
+                    <div className="flex items-center gap-2">
+                      <div className="text-xs text-muted-foreground">Auth:</div>
+                      <Tabs
+                        value={authMethod}
+                        onValueChange={(v) =>
+                          setAuthMethod(v as "query" | "header")
+                        }
+                        className="h-7"
+                      >
+                        <TabsList className="h-7 p-0.5">
+                          <TabsTrigger
+                            value="query"
+                            className="h-6 text-xs px-2 py-0.5 flex items-center gap-1"
+                          >
+                            <KeyRound className="h-3 w-3" />
+                            Query
+                          </TabsTrigger>
+                          <TabsTrigger
+                            value="header"
+                            className="h-6 text-xs px-2 py-0.5 flex items-center gap-1"
+                          >
+                            <ShieldAlert className="h-3 w-3" />
+                            Header
+                          </TabsTrigger>
+                        </TabsList>
+                      </Tabs>
+                    </div>
+                  </div>
+
+                  {mcp.endpoints?.sse && (
+                    <div className="mb-3">
+                      <div className="flex items-center justify-between mb-1">
+                        <div className="text-sm font-medium">SSE:</div>
+                        <CopyButton
+                          text={formatEndpointUrl(
+                            mcp.endpoints.host,
+                            mcp.endpoints.sse
+                          )}
+                        />
+                      </div>
+                      <div className="relative">
+                        <div className="text-xs bg-muted p-2 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                          {authMethod === "query"
+                            ? getAuthenticatedUrl(
+                                formatEndpointUrl(
+                                  mcp.endpoints.host,
+                                  mcp.endpoints.sse
+                                )
+                              )
+                            : formatEndpointUrl(
+                                mcp.endpoints.host,
+                                mcp.endpoints.sse
+                              )}
+                        </div>
+                      </div>
+                    </div>
+                  )}
+
+                  {mcp.endpoints?.streamable_http && (
+                    <div>
+                      <div className="flex items-center justify-between mb-1">
+                        <div className="text-sm font-medium">HTTP:</div>
+                        <CopyButton
+                          text={formatEndpointUrl(
+                            mcp.endpoints.host,
+                            mcp.endpoints.streamable_http
+                          )}
+                        />
+                      </div>
+                      <div className="relative">
+                        <div className="text-xs bg-muted p-2 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                          {authMethod === "query"
+                            ? getAuthenticatedUrl(
+                                formatEndpointUrl(
+                                  mcp.endpoints.host,
+                                  mcp.endpoints.streamable_http
+                                )
+                              )
+                            : formatEndpointUrl(
+                                mcp.endpoints.host,
+                                mcp.endpoints.streamable_http
+                              )}
+                        </div>
+                      </div>
+                    </div>
+                  )}
+
+                  <div className="mt-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded-md">
+                    <div className="flex items-center gap-1 mb-1">
+                      <ShieldAlert className="h-3 w-3" />
+                      <span className="font-medium">
+                        Authentication Required:
+                      </span>
+                    </div>
+                    {authMethod === "query" ? (
+                      <div>
+                        <div className="text-xs mb-1">Add query parameter:</div>
+                        <div className="flex items-center gap-2">
+                          <code className="block flex-1 font-mono bg-muted p-1 rounded">
+                            key=
+                            <span className="text-blue-500">your-token</span>
+                          </code>
+                          <CopyButton
+                            text="key=your-token"
+                            className="h-6 w-6 p-0"
+                          />
+                        </div>
+                      </div>
+                    ) : (
+                      <div>
+                        <div className="text-xs mb-1">Add HTTP header:</div>
+                        <div className="flex items-center gap-2">
+                          <code className="block flex-1 font-mono bg-muted p-1 rounded">
+                            Authorization: Bearer{" "}
+                            <span className="text-blue-500">your-token</span>
+                          </code>
+                          <CopyButton
+                            text="Authorization: Bearer your-token"
+                            className="h-6 w-6 p-0"
+                          />
+                        </div>
+                      </div>
+                    )}
+                  </div>
+                </div>
+              )}
+
+              <div className="text-sm text-muted-foreground mt-2">
+                <div>Created: {new Date(mcp.created_at).toLocaleString()}</div>
+                <div>Updated: {new Date(mcp.update_at).toLocaleString()}</div>
+              </div>
+            </CardContent>
+          </Card>
+        ))}
+      </div>
+    );
+  }
+};
+
+export default MCPConfig;

+ 516 - 0
web/src/pages/mcp/components/MCPList.tsx

@@ -0,0 +1,516 @@
+import { useState, useEffect } from "react";
+import { useToast } from "@/components/ui/use-toast";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { PublicMCP, getAllMCPs } from "@/api/mcp";
+import { CopyButton } from "@/components/common/CopyButton";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { useTranslation } from "react-i18next";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { KeyRound, ShieldAlert } from "lucide-react";
+
+const MCPList = () => {
+  const [mcps, setMcps] = useState<PublicMCP[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [searchTerm, setSearchTerm] = useState("");
+  const [authMethod, setAuthMethod] = useState<"query" | "header">("query");
+  const { toast } = useToast();
+  const { t } = useTranslation();
+
+  useEffect(() => {
+    fetchMCPs();
+  }, []);
+
+  const fetchMCPs = async () => {
+    try {
+      setLoading(true);
+      const data = await getAllMCPs();
+      // 只保留状态为1(已启用)的MCP
+      const enabledMCPs = data.filter((mcp) => mcp.status === 1);
+      setMcps(enabledMCPs);
+    } catch (err) {
+      toast({
+        title: t("error.loading"),
+        description: t("mcp.list.noResults"),
+        variant: "destructive",
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const truncateReadme = (readme: string, maxLength = 100) => {
+    if (readme.length <= maxLength) return readme;
+    return readme.substring(0, maxLength) + "...";
+  };
+
+  // 移除Markdown语法以显示纯文本预览
+  const stripMarkdown = (markdown: string) => {
+    return markdown
+      .replace(/#+\s+/g, "") // 移除标题标记
+      .replace(/\*\*(.*?)\*\*/g, "$1") // 移除加粗
+      .replace(/\*(.*?)\*/g, "$1") // 移除斜体
+      .replace(/\[(.*?)\]\(.*?\)/g, "$1") // 移除链接,只保留文本
+      .replace(/`{1,3}(.*?)`{1,3}/g, "$1") // 移除代码块
+      .replace(/~~(.*?)~~/g, "$1") // 移除删除线
+      .replace(/>\s+(.*?)\n/g, "$1\n") // 移除引用
+      .replace(/\n\s*[-*+]\s+/g, "\n"); // 移除列表标记
+  };
+
+  // 获取当前协议
+  const protocol = window.location.protocol;
+
+  // 格式化URL,确保带有正确的协议前缀
+  const formatEndpointUrl = (host: string, path: string) => {
+    // 如果host已经包含协议,直接返回完整URL
+    if (host.startsWith("http://") || host.startsWith("https://")) {
+      return `${host}${path}`;
+    }
+
+    // 否则,根据当前页面协议添加协议前缀
+    return `${protocol}//${host}${path}`;
+  };
+
+  // 获取认证的URL
+  const getAuthenticatedUrl = (url: string) => {
+    if (authMethod === "query") {
+      return `${url}${url.includes("?") ? "&" : "?"}key=your-token`;
+    }
+    return url;
+  };
+
+  // 获取认证详细信息
+  const getAuthDetails = () => {
+    if (authMethod === "query") {
+      return (
+        <div>
+          <div className="text-xs mb-1">Add query parameter:</div>
+          <div className="flex items-center gap-2">
+            <code className="block flex-1 font-mono bg-muted p-1 rounded">
+              key=<span className="text-blue-500">your-token</span>
+            </code>
+            <CopyButton text="key=your-token" className="h-6 w-6 p-0" />
+          </div>
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <div className="text-xs mb-1">Add HTTP header:</div>
+          <div className="flex items-center gap-2">
+            <code className="block flex-1 font-mono bg-muted p-1 rounded">
+              Authorization: Bearer{" "}
+              <span className="text-blue-500">your-token</span>
+            </code>
+            <CopyButton
+              text="Authorization: Bearer your-token"
+              className="h-6 w-6 p-0"
+            />
+          </div>
+        </div>
+      );
+    }
+  };
+
+  const filteredMCPs = mcps.filter(
+    (mcp) =>
+      mcp.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+      mcp.tags?.some((tag) =>
+        tag.toLowerCase().includes(searchTerm.toLowerCase())
+      )
+  );
+
+  if (loading) {
+    return <div className="flex justify-center p-8">{t("common.loading")}</div>;
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <Input
+          className="max-w-xs"
+          placeholder={t("mcp.list.search")}
+          value={searchTerm}
+          onChange={(e) => setSearchTerm(e.target.value)}
+        />
+        <Button onClick={fetchMCPs}>{t("mcp.refresh")}</Button>
+      </div>
+
+      {filteredMCPs.length === 0 ? (
+        <div className="text-center p-8">{t("mcp.list.noResults")}</div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          {filteredMCPs.map((mcp) => (
+            <Dialog
+              key={mcp.id}
+            >
+              <DialogTrigger asChild>
+                <Card className="overflow-hidden cursor-pointer hover:shadow-md transition-shadow">
+                  <CardHeader>
+                    <div className="flex justify-between items-start">
+                      <div>
+                        <CardTitle className="flex items-center">
+                          {mcp.name}
+                        </CardTitle>
+                        <div className="text-sm text-muted-foreground">
+                          {mcp.id}
+                        </div>
+                      </div>
+                    </div>
+                    <div className="flex flex-wrap gap-1 mt-1">
+                      {mcp.tags?.map((tag) => (
+                        <Badge key={tag} variant="outline">
+                          {tag}
+                        </Badge>
+                      ))}
+                    </div>
+                  </CardHeader>
+                  <CardContent className="space-y-2">
+                    {mcp.readme && (
+                      <div className="p-3 bg-muted rounded-md text-sm mb-2">
+                        <div className="font-medium mb-1">
+                          {t("mcp.description")}:
+                        </div>
+                        <div className="text-muted-foreground line-clamp-3">
+                          {truncateReadme(stripMarkdown(mcp.readme))}
+                        </div>
+                      </div>
+                    )}
+
+                    {(mcp.endpoints.sse || mcp.endpoints.streamable_http) && (
+                      <div>
+                        <div className="flex items-center justify-between mb-1">
+                          <div className="font-medium">
+                            {t("mcp.endpoint")}:
+                          </div>
+                          <div
+                            className="flex items-center gap-2"
+                            onClick={(e) => e.stopPropagation()}
+                          >
+                            <div className="text-xs text-muted-foreground">
+                              Auth:
+                            </div>
+                            <Tabs
+                              value={authMethod}
+                              onValueChange={(v) =>
+                                setAuthMethod(v as "query" | "header")
+                              }
+                              className="h-6"
+                            >
+                              <TabsList className="h-6 p-0.5">
+                                <TabsTrigger
+                                  value="query"
+                                  className="h-5 text-xs px-1.5 py-0 flex items-center gap-1"
+                                >
+                                  <KeyRound className="h-3 w-3" />
+                                  <span className="hidden sm:inline">
+                                    Query
+                                  </span>
+                                </TabsTrigger>
+                                <TabsTrigger
+                                  value="header"
+                                  className="h-5 text-xs px-1.5 py-0 flex items-center gap-1"
+                                >
+                                  <ShieldAlert className="h-3 w-3" />
+                                  <span className="hidden sm:inline">
+                                    Header
+                                  </span>
+                                </TabsTrigger>
+                              </TabsList>
+                            </Tabs>
+                          </div>
+                        </div>
+
+                        {mcp.endpoints.sse && (
+                          <div
+                            className="mb-1"
+                            onClick={(e) => e.stopPropagation()}
+                          >
+                            <div className="flex items-center justify-between mb-0.5">
+                              <div className="text-xs font-medium">SSE:</div>
+                              <CopyButton
+                                text={formatEndpointUrl(
+                                  mcp.endpoints.host,
+                                  mcp.endpoints.sse
+                                )}
+                                className="h-5 w-5 p-0"
+                              />
+                            </div>
+                            <div className="text-xs bg-muted p-1.5 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                              {authMethod === "query"
+                                ? getAuthenticatedUrl(
+                                    formatEndpointUrl(
+                                      mcp.endpoints.host,
+                                      mcp.endpoints.sse
+                                    )
+                                  )
+                                : formatEndpointUrl(
+                                    mcp.endpoints.host,
+                                    mcp.endpoints.sse
+                                  )}
+                            </div>
+                          </div>
+                        )}
+
+                        {mcp.endpoints.streamable_http && (
+                          <div onClick={(e) => e.stopPropagation()}>
+                            <div className="flex items-center justify-between mb-0.5">
+                              <div className="text-xs font-medium">HTTP:</div>
+                              <CopyButton
+                                text={formatEndpointUrl(
+                                  mcp.endpoints.host,
+                                  mcp.endpoints.streamable_http
+                                )}
+                                className="h-5 w-5 p-0"
+                              />
+                            </div>
+                            <div className="text-xs bg-muted p-1.5 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                              {authMethod === "query"
+                                ? getAuthenticatedUrl(
+                                    formatEndpointUrl(
+                                      mcp.endpoints.host,
+                                      mcp.endpoints.streamable_http
+                                    )
+                                  )
+                                : formatEndpointUrl(
+                                    mcp.endpoints.host,
+                                    mcp.endpoints.streamable_http
+                                  )}
+                            </div>
+                          </div>
+                        )}
+
+                        <div className="mt-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded-md">
+                          <div className="flex items-center gap-1 mb-1">
+                            <ShieldAlert className="h-3 w-3" />
+                            <span className="font-medium">
+                              Authentication Required:
+                            </span>
+                          </div>
+                          {authMethod === "query" ? (
+                            <div>
+                              <div className="text-xs mb-1">
+                                Add query parameter:
+                              </div>
+                              <div className="flex items-center gap-2">
+                                <code className="block flex-1 font-mono bg-muted p-1 rounded">
+                                  key=
+                                  <span className="text-blue-500">
+                                    your-token
+                                  </span>
+                                </code>
+                                <CopyButton
+                                  text="key=your-token"
+                                  className="h-6 w-6 p-0"
+                                />
+                              </div>
+                            </div>
+                          ) : (
+                            <div>
+                              <div className="text-xs mb-1">
+                                Add HTTP header:
+                              </div>
+                              <div className="flex items-center gap-2">
+                                <code className="block flex-1 font-mono bg-muted p-1 rounded">
+                                  Authorization: Bearer{" "}
+                                  <span className="text-blue-500">
+                                    your-token
+                                  </span>
+                                </code>
+                                <CopyButton
+                                  text="Authorization: Bearer your-token"
+                                  className="h-6 w-6 p-0"
+                                />
+                              </div>
+                            </div>
+                          )}
+                        </div>
+                      </div>
+                    )}
+                  </CardContent>
+                </Card>
+              </DialogTrigger>
+              <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
+                <DialogHeader>
+                  <DialogTitle className="flex items-center">
+                    {mcp.name}
+                  </DialogTitle>
+                </DialogHeader>
+                <div className="space-y-4">
+                  <div className="flex items-center space-x-2">
+                    <span className="font-medium">ID:</span>
+                    <span>{mcp.id}</span>
+                  </div>
+
+                  {mcp.tags && mcp.tags.length > 0 && (
+                    <div>
+                      <div className="font-medium mb-1">{t("mcp.tags")}:</div>
+                      <div className="flex flex-wrap gap-1">
+                        {mcp.tags.map((tag) => (
+                          <Badge key={tag} variant="outline">
+                            {tag}
+                          </Badge>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+
+                  {mcp.readme && (
+                    <div>
+                      <div className="font-medium mb-1">
+                        {t("mcp.description")}:
+                      </div>
+                      <div className="p-4 bg-muted rounded-md max-h-[300px] overflow-y-auto">
+                        <div className="prose prose-sm dark:prose-invert max-w-none">
+                          <ReactMarkdown remarkPlugins={[remarkGfm]}>
+                            {mcp.readme}
+                          </ReactMarkdown>
+                        </div>
+                      </div>
+                    </div>
+                  )}
+
+                  {(mcp.endpoints.sse || mcp.endpoints.streamable_http) && (
+                    <div>
+                      <div className="flex items-center justify-between mb-2">
+                        <div className="font-medium">{t("mcp.endpoint")}:</div>
+                        <div className="flex items-center gap-2">
+                          <div className="text-xs text-muted-foreground">
+                            Auth:
+                          </div>
+                          <Tabs
+                            value={authMethod}
+                            onValueChange={(v) =>
+                              setAuthMethod(v as "query" | "header")
+                            }
+                            className="h-7"
+                          >
+                            <TabsList className="h-7 p-0.5">
+                              <TabsTrigger
+                                value="query"
+                                className="h-6 text-xs px-2 py-0.5 flex items-center gap-1"
+                              >
+                                <KeyRound className="h-3 w-3" />
+                                Query
+                              </TabsTrigger>
+                              <TabsTrigger
+                                value="header"
+                                className="h-6 text-xs px-2 py-0.5 flex items-center gap-1"
+                              >
+                                <ShieldAlert className="h-3 w-3" />
+                                Header
+                              </TabsTrigger>
+                            </TabsList>
+                          </Tabs>
+                        </div>
+                      </div>
+
+                      {mcp.endpoints.sse && (
+                        <div className="mb-3">
+                          <div className="flex items-center justify-between mb-1">
+                            <div className="text-sm font-medium">
+                              {t("mcp.list.endpointsSse")}:
+                            </div>
+                            <CopyButton
+                              text={formatEndpointUrl(
+                                mcp.endpoints.host,
+                                mcp.endpoints.sse
+                              )}
+                            />
+                          </div>
+                          <div className="relative">
+                            <div className="text-xs bg-muted p-2 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                              {authMethod === "query"
+                                ? getAuthenticatedUrl(
+                                    formatEndpointUrl(
+                                      mcp.endpoints.host,
+                                      mcp.endpoints.sse
+                                    )
+                                  )
+                                : formatEndpointUrl(
+                                    mcp.endpoints.host,
+                                    mcp.endpoints.sse
+                                  )}
+                            </div>
+                          </div>
+                        </div>
+                      )}
+
+                      {mcp.endpoints.streamable_http && (
+                        <div>
+                          <div className="flex items-center justify-between mb-1">
+                            <div className="text-sm font-medium">
+                              {t("mcp.list.endpointsHttp")}:
+                            </div>
+                            <CopyButton
+                              text={formatEndpointUrl(
+                                mcp.endpoints.host,
+                                mcp.endpoints.streamable_http
+                              )}
+                            />
+                          </div>
+                          <div className="relative">
+                            <div className="text-xs bg-muted p-2 rounded-md overflow-x-auto whitespace-nowrap font-mono">
+                              {authMethod === "query"
+                                ? getAuthenticatedUrl(
+                                    formatEndpointUrl(
+                                      mcp.endpoints.host,
+                                      mcp.endpoints.streamable_http
+                                    )
+                                  )
+                                : formatEndpointUrl(
+                                    mcp.endpoints.host,
+                                    mcp.endpoints.streamable_http
+                                  )}
+                            </div>
+                          </div>
+                        </div>
+                      )}
+
+                      <div className="mt-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded-md">
+                        <div className="flex items-center gap-1 mb-1">
+                          <ShieldAlert className="h-3 w-3" />
+                          <span className="font-medium">
+                            Authentication Required:
+                          </span>
+                        </div>
+                        {getAuthDetails()}
+                      </div>
+                    </div>
+                  )}
+
+                  <div className="flex items-center space-x-2">
+                    <span className="font-medium">
+                      {t("mcp.list.createdAt")}:
+                    </span>
+                    <span>{new Date(mcp.created_at).toLocaleString()}</span>
+                  </div>
+
+                  <div className="flex items-center space-x-2">
+                    <span className="font-medium">
+                      {t("mcp.list.updatedAt")}:
+                    </span>
+                    <span>{new Date(mcp.update_at).toLocaleString()}</span>
+                  </div>
+                </div>
+              </DialogContent>
+            </Dialog>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default MCPList;

+ 102 - 0
web/src/pages/mcp/components/config/OpenAPIConfig.tsx

@@ -0,0 +1,102 @@
+import { useState } from 'react'
+import { MCPOpenAPIConfig } from '@/api/mcp'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+
+interface OpenAPIConfigProps {
+  config: MCPOpenAPIConfig | undefined
+  onChange: (config: MCPOpenAPIConfig) => void
+}
+
+const OpenAPIConfig = ({ config, onChange }: OpenAPIConfigProps) => {
+  const [openApiConfig, setOpenApiConfig] = useState<MCPOpenAPIConfig>(
+    config || {
+      openapi_spec: '',
+      openapi_content: '',
+      v2: false,
+      server_addr: '',
+      authorization: ''
+    }
+  )
+
+  const handleChange = (field: keyof MCPOpenAPIConfig, value: string | boolean) => {
+    const newConfig = { ...openApiConfig, [field]: value }
+    setOpenApiConfig(newConfig)
+    onChange(newConfig)
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center space-x-2">
+        <Switch
+          id="v2"
+          checked={openApiConfig.v2}
+          onCheckedChange={(checked) => handleChange('v2', checked)}
+        />
+        <Label htmlFor="v2">Use OpenAPI v2 (Swagger)</Label>
+      </div>
+
+      <Tabs defaultValue="url">
+        <TabsList className="grid grid-cols-2">
+          <TabsTrigger value="url">Specification URL</TabsTrigger>
+          <TabsTrigger value="content">Specification Content</TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="url" className="space-y-4 pt-4">
+          <div className="space-y-2">
+            <Label htmlFor="openapi_spec">OpenAPI Specification URL</Label>
+            <Input
+              id="openapi_spec"
+              value={openApiConfig.openapi_spec}
+              onChange={(e) => handleChange('openapi_spec', e.target.value)}
+              placeholder="https://example.com/openapi.json"
+            />
+            <p className="text-xs text-muted-foreground">URL to your OpenAPI/Swagger specification</p>
+          </div>
+        </TabsContent>
+
+        <TabsContent value="content" className="space-y-4 pt-4">
+          <div className="space-y-2">
+            <Label htmlFor="openapi_content">OpenAPI Specification Content</Label>
+            <Textarea
+              id="openapi_content"
+              value={openApiConfig.openapi_content || ''}
+              onChange={(e) => handleChange('openapi_content', e.target.value)}
+              placeholder="Paste your OpenAPI/Swagger JSON or YAML here"
+              className="min-h-[300px] font-mono"
+            />
+            <p className="text-xs text-muted-foreground">Paste your OpenAPI specification (JSON or YAML format)</p>
+          </div>
+        </TabsContent>
+      </Tabs>
+
+      <div className="space-y-2">
+        <Label htmlFor="server_addr">Server Address (Optional)</Label>
+        <Input
+          id="server_addr"
+          value={openApiConfig.server_addr || ''}
+          onChange={(e) => handleChange('server_addr', e.target.value)}
+          placeholder="https://api.example.com"
+        />
+        <p className="text-xs text-muted-foreground">Override the server address defined in the specification</p>
+      </div>
+
+      <div className="space-y-2">
+        <Label htmlFor="authorization">Authorization (Optional)</Label>
+        <Input
+          id="authorization"
+          value={openApiConfig.authorization || ''}
+          onChange={(e) => handleChange('authorization', e.target.value)}
+          placeholder="Bearer token123"
+          type="password"
+        />
+        <p className="text-xs text-muted-foreground">Default authorization header to include with all requests</p>
+      </div>
+    </div>
+  )
+}
+
+export default OpenAPIConfig 

+ 349 - 0
web/src/pages/mcp/components/config/ProxyConfig.tsx

@@ -0,0 +1,349 @@
+import { useState } from 'react'
+import { PublicMCPProxyConfig, ReusingParam } from '@/api/mcp'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+
+interface ProxyConfigProps {
+  config: PublicMCPProxyConfig | undefined
+  onChange: (config: PublicMCPProxyConfig) => void
+}
+
+const ProxyConfig = ({ config, onChange }: ProxyConfigProps) => {
+  const [proxyConfig, setProxyConfig] = useState<PublicMCPProxyConfig>(
+    config || {
+      url: '',
+      headers: {},
+      querys: {},
+      reusing_params: {}
+    }
+  )
+
+  // 添加键值对的临时状态
+  const [newHeaderKey, setNewHeaderKey] = useState('')
+  const [newHeaderValue, setNewHeaderValue] = useState('')
+  const [newQueryKey, setNewQueryKey] = useState('')
+  const [newQueryValue, setNewQueryValue] = useState('')
+  const [newReusingKey, setNewReusingKey] = useState('')
+  const [newReusingParam, setNewReusingParam] = useState<ReusingParam>({
+    name: '',
+    description: '',
+    required: false,
+    type: 'header'
+  })
+
+  const handleURLChange = (url: string) => {
+    const newConfig = { ...proxyConfig, url }
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+  }
+
+  const addHeader = () => {
+    if (!newHeaderKey.trim()) return
+    
+    const newHeaders = { 
+      ...proxyConfig.headers, 
+      [newHeaderKey]: newHeaderValue 
+    }
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      headers: newHeaders 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+    setNewHeaderKey('')
+    setNewHeaderValue('')
+  }
+
+  const removeHeader = (key: string) => {
+    const newHeaders = { ...proxyConfig.headers }
+    delete newHeaders[key]
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      headers: newHeaders 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+  }
+
+  const addQuery = () => {
+    if (!newQueryKey.trim()) return
+    
+    const newQuerys = { 
+      ...proxyConfig.querys, 
+      [newQueryKey]: newQueryValue 
+    }
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      querys: newQuerys 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+    setNewQueryKey('')
+    setNewQueryValue('')
+  }
+
+  const removeQuery = (key: string) => {
+    const newQuerys = { ...proxyConfig.querys }
+    delete newQuerys[key]
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      querys: newQuerys 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+  }
+
+  const addReusingParam = () => {
+    if (!newReusingKey.trim() || !newReusingParam.name.trim()) return
+    
+    const newReusingParams = { 
+      ...proxyConfig.reusing_params, 
+      [newReusingKey]: { ...newReusingParam } 
+    }
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      reusing_params: newReusingParams 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+    setNewReusingKey('')
+    setNewReusingParam({
+      name: '',
+      description: '',
+      required: false,
+      type: 'header'
+    })
+  }
+
+  const removeReusingParam = (key: string) => {
+    const newReusingParams = { ...proxyConfig.reusing_params }
+    delete newReusingParams[key]
+    
+    const newConfig = { 
+      ...proxyConfig, 
+      reusing_params: newReusingParams 
+    }
+    
+    setProxyConfig(newConfig)
+    onChange(newConfig)
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="space-y-2">
+        <Label htmlFor="url">Backend URL <span className="text-red-500">*</span></Label>
+        <Input
+          id="url"
+          value={proxyConfig.url}
+          onChange={(e) => handleURLChange(e.target.value)}
+          placeholder="https://example.com/api"
+        />
+        <p className="text-xs text-muted-foreground">The backend URL to proxy requests to</p>
+      </div>
+
+      <Tabs defaultValue="headers">
+        <TabsList className="grid grid-cols-3">
+          <TabsTrigger value="headers">Headers</TabsTrigger>
+          <TabsTrigger value="query">Query Parameters</TabsTrigger>
+          <TabsTrigger value="reusing">Reusing Parameters</TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="headers" className="space-y-4 pt-4">
+          <div className="space-y-2">
+            <div className="flex gap-2">
+              <Input
+                placeholder="Header Name"
+                value={newHeaderKey}
+                onChange={(e) => setNewHeaderKey(e.target.value)}
+              />
+              <Input
+                placeholder="Header Value"
+                value={newHeaderValue}
+                onChange={(e) => setNewHeaderValue(e.target.value)}
+              />
+              <Button type="button" onClick={addHeader}>Add</Button>
+            </div>
+          </div>
+
+          {Object.keys(proxyConfig.headers).length === 0 ? (
+            <div className="text-center text-muted-foreground py-4">
+              No headers configured
+            </div>
+          ) : (
+            <div className="space-y-2">
+              {Object.entries(proxyConfig.headers).map(([key, value]) => (
+                <div key={key} className="flex items-center gap-2 p-2 bg-muted rounded-md">
+                  <div className="flex-1">
+                    <div className="font-medium">{key}</div>
+                    <div className="text-sm text-muted-foreground">{value}</div>
+                  </div>
+                  <Button variant="ghost" size="sm" onClick={() => removeHeader(key)}>
+                    Remove
+                  </Button>
+                </div>
+              ))}
+            </div>
+          )}
+        </TabsContent>
+
+        <TabsContent value="query" className="space-y-4 pt-4">
+          <div className="space-y-2">
+            <div className="flex gap-2">
+              <Input
+                placeholder="Parameter Name"
+                value={newQueryKey}
+                onChange={(e) => setNewQueryKey(e.target.value)}
+              />
+              <Input
+                placeholder="Parameter Value"
+                value={newQueryValue}
+                onChange={(e) => setNewQueryValue(e.target.value)}
+              />
+              <Button type="button" onClick={addQuery}>Add</Button>
+            </div>
+          </div>
+
+          {Object.keys(proxyConfig.querys).length === 0 ? (
+            <div className="text-center text-muted-foreground py-4">
+              No query parameters configured
+            </div>
+          ) : (
+            <div className="space-y-2">
+              {Object.entries(proxyConfig.querys).map(([key, value]) => (
+                <div key={key} className="flex items-center gap-2 p-2 bg-muted rounded-md">
+                  <div className="flex-1">
+                    <div className="font-medium">{key}</div>
+                    <div className="text-sm text-muted-foreground">{value}</div>
+                  </div>
+                  <Button variant="ghost" size="sm" onClick={() => removeQuery(key)}>
+                    Remove
+                  </Button>
+                </div>
+              ))}
+            </div>
+          )}
+        </TabsContent>
+
+        <TabsContent value="reusing" className="space-y-4 pt-4">
+          <div className="space-y-4">
+            <div className="space-y-2">
+              <Label htmlFor="reusingKey">Parameter Key</Label>
+              <Input
+                id="reusingKey"
+                placeholder="e.g., api_key"
+                value={newReusingKey}
+                onChange={(e) => setNewReusingKey(e.target.value)}
+              />
+            </div>
+            
+            <div className="space-y-2">
+              <Label htmlFor="reusingName">Display Name</Label>
+              <Input
+                id="reusingName"
+                placeholder="e.g., API Key"
+                value={newReusingParam.name}
+                onChange={(e) => setNewReusingParam({...newReusingParam, name: e.target.value})}
+              />
+            </div>
+            
+            <div className="space-y-2">
+              <Label htmlFor="reusingDescription">Description</Label>
+              <Textarea
+                id="reusingDescription"
+                placeholder="Describe what this parameter is for"
+                value={newReusingParam.description}
+                onChange={(e) => setNewReusingParam({...newReusingParam, description: e.target.value})}
+              />
+            </div>
+            
+            <div className="space-y-2">
+              <Label htmlFor="reusingType">Parameter Type</Label>
+              <Select
+                value={newReusingParam.type}
+                onValueChange={(value: 'header' | 'query') => setNewReusingParam({...newReusingParam, type: value})}
+              >
+                <SelectTrigger>
+                  <SelectValue placeholder="Select parameter type" />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="header">Header</SelectItem>
+                  <SelectItem value="query">Query Parameter</SelectItem>
+                </SelectContent>
+              </Select>
+            </div>
+            
+            <div className="flex items-center space-x-2">
+              <Switch
+                id="required"
+                checked={newReusingParam.required}
+                onCheckedChange={(checked) => setNewReusingParam({...newReusingParam, required: checked})}
+              />
+              <Label htmlFor="required">Required</Label>
+            </div>
+            
+            <Button type="button" onClick={addReusingParam}>
+              Add Reusing Parameter
+            </Button>
+          </div>
+
+          {Object.keys(proxyConfig.reusing_params).length === 0 ? (
+            <div className="text-center text-muted-foreground py-4">
+              No reusing parameters configured
+            </div>
+          ) : (
+            <div className="space-y-2">
+              {Object.entries(proxyConfig.reusing_params).map(([key, param]) => (
+                <Card key={key}>
+                  <CardHeader>
+                    <CardTitle className="text-base flex justify-between">
+                      <span>{key}</span>
+                      <Button variant="ghost" size="sm" onClick={() => removeReusingParam(key)}>
+                        Remove
+                      </Button>
+                    </CardTitle>
+                  </CardHeader>
+                  <CardContent>
+                    <div className="space-y-1">
+                      <div className="text-sm">
+                        <span className="font-medium">Name:</span> {param.name}
+                      </div>
+                      <div className="text-sm">
+                        <span className="font-medium">Type:</span> {param.type}
+                      </div>
+                      <div className="text-sm">
+                        <span className="font-medium">Required:</span> {param.required ? 'Yes' : 'No'}
+                      </div>
+                      {param.description && (
+                        <div className="text-sm">
+                          <span className="font-medium">Description:</span> {param.description}
+                        </div>
+                      )}
+                    </div>
+                  </CardContent>
+                </Card>
+              ))}
+            </div>
+          )}
+        </TabsContent>
+      </Tabs>
+    </div>
+  )
+}
+
+export default ProxyConfig 

+ 37 - 0
web/src/pages/mcp/page.tsx

@@ -0,0 +1,37 @@
+import { useState } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import MCPList from "@/pages/mcp/components/MCPList";
+import EmbedMCP from "@/pages/mcp/components/EmbedMCP";
+import MCPConfig from "@/pages/mcp/components/MCPConfig";
+
+const MCPPage = () => {
+  const [activeTab, setActiveTab] = useState("list");
+
+  return (
+    <div className="container mx-auto p-4 space-y-4">
+      <h1 className="text-2xl font-bold">MCP Management</h1>
+
+      <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+        <TabsList className="grid grid-cols-3 w-full max-w-md">
+          <TabsTrigger value="list">MCP List</TabsTrigger>
+          <TabsTrigger value="embed">Embed MCP</TabsTrigger>
+          <TabsTrigger value="config">MCP Config</TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="list">
+          <MCPList />
+        </TabsContent>
+
+        <TabsContent value="embed">
+          <EmbedMCP />
+        </TabsContent>
+
+        <TabsContent value="config">
+          <MCPConfig />
+        </TabsContent>
+      </Tabs>
+    </div>
+  );
+};
+
+export default MCPPage;

+ 5 - 0
web/src/routes/config.tsx

@@ -10,6 +10,7 @@ import ChannelPage from "@/pages/channel/page"
 import TokenPage from "@/pages/token/page"
 import MonitorPage from "@/pages/monitor/page"
 import LogPage from "@/pages/log/page"
+import MCPPage from "@/pages/mcp/page"
 
 // import layout component directly
 import { RootLayout } from "@/components/layout/RootLayOut"
@@ -64,6 +65,10 @@ export function useRoutes(): RouteObject[] {
                 {
                     path: ROUTES.LOG,
                     element: <LogPage />,
+                },
+                {
+                    path: ROUTES.MCP,
+                    element: <MCPPage />,
                 }
             ]
         }]

+ 1 - 0
web/src/routes/constants.ts

@@ -6,6 +6,7 @@ export const ROUTES = {
     CHANNEL: "/channel",
     MODEL: "/model",
     LOG: "/log",
+    MCP: "/mcp",
 } as const
 
 export type RouteKey = keyof typeof ROUTES

+ 6 - 3
web/src/types/channel.ts

@@ -4,7 +4,7 @@ export interface Channel {
     type: number
     name: string
     key: string
-    base_url: string
+    base_url?: string
     models: string[]
     model_mapping: Record<string, string> | null
     request_count: number
@@ -13,6 +13,7 @@ export interface Channel {
     priority: number
     balance?: number
     used_amount?: number
+    sets?: string[]
 }
 
 export interface ChannelTypeMeta {
@@ -32,18 +33,20 @@ export interface ChannelCreateRequest {
     type: number
     name: string
     key: string
-    base_url: string
+    base_url?: string
     models: string[]
     model_mapping?: Record<string, string>
+    sets?: string[]
 }
 
 export interface ChannelUpdateRequest {
     type: number
     name: string
     key: string
-    base_url: string
+    base_url?: string
     models: string[]
     model_mapping?: Record<string, string>
+    sets?: string[]
 }
 
 export interface ChannelStatusRequest {

+ 4 - 0
web/src/types/model.ts

@@ -37,6 +37,10 @@ export interface ModelConfig {
     price: ModelPrice
     rpm: number
     tpm?: number
+    retry_times?: number
+    timeout?: number
+    max_error_rate?: number
+    force_save_detail?: boolean
     plugin: Plugin
 }
 

+ 3 - 2
web/src/validation/channel.ts

@@ -5,9 +5,10 @@ export const channelCreateSchema = z.object({
     type: z.number().min(1, '厂商不能为空'),
     name: z.string().min(1, '名称不能为空'),
     key: z.string().min(1, '密钥不能为空'),
-    base_url: z.string().min(1, '代理地址不能为空'),
+    base_url: z.string().optional(),
     models: z.array(z.string()).min(1, '至少选择一个模型'),
-    model_mapping: z.record(z.string(), z.string()).optional()
+    model_mapping: z.record(z.string(), z.string()).optional(),
+    sets: z.array(z.string()).optional()
 })
 
 export type ChannelCreateForm = z.infer<typeof channelCreateSchema>

+ 6 - 0
web/src/validation/model.ts

@@ -69,6 +69,12 @@ const pluginSchema = z.object({
 export const modelCreateSchema = z.object({
     model: z.string().min(1, 'Model name is required'),
     type: z.number().min(0, 'Type is required'),
+    rpm: z.number().nonnegative('RPM must be a non-negative number').optional(),
+    tpm: z.number().nonnegative('TPM must be a non-negative number').optional(),
+    retry_times: z.number().nonnegative('Retry times must be a non-negative number').optional(),
+    timeout: z.number().nonnegative('Timeout must be a non-negative number').optional(),
+    max_error_rate: z.number().min(0, 'Error rate must be at least 0').max(1, 'Error rate must be at most 1').optional(),
+    force_save_detail: z.boolean().optional(),
     plugin: pluginSchema,
 })
 

+ 21 - 0
web/tailwind.config.js

@@ -0,0 +1,21 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  darkMode: ["class"],
+  content: [
+    './pages/**/*.{ts,tsx}',
+    './components/**/*.{ts,tsx}',
+    './app/**/*.{ts,tsx}',
+    './src/**/*.{ts,tsx}',
+	],
+  theme: {
+    container: {
+      center: true,
+      padding: "2rem",
+      screens: {
+        "2xl": "1400px",
+      },
+    },
+    extend: {},
+  },
+  plugins: [require("@tailwindcss/typography")],
+} 

Некоторые файлы не были показаны из-за большого количества измененных файлов