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

Merge pull request #3129 from seefs001/feature/param-override-wildcard-path

Feature/param override wildcard path
Calcium-Ion 2 месяцев назад
Родитель
Сommit
5df8b34f78

+ 183 - 16
relay/common/override.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"net/http"
 	"regexp"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -487,15 +488,35 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 		}
 		// 处理路径中的负数索引
 		opPath := processNegativeIndex(result, op.Path)
+		var opPaths []string
+		if isPathBasedOperation(op.Mode) {
+			opPaths, err = resolveOperationPaths(result, opPath)
+			if err != nil {
+				return "", err
+			}
+			if len(opPaths) == 0 {
+				continue
+			}
+		}
 
 		switch op.Mode {
 		case "delete":
-			result, err = sjson.Delete(result, opPath)
+			for _, path := range opPaths {
+				result, err = deleteValue(result, path)
+				if err != nil {
+					break
+				}
+			}
 		case "set":
-			if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
-				continue
+			for _, path := range opPaths {
+				if op.KeepOrigin && gjson.Get(result, path).Exists() {
+					continue
+				}
+				result, err = sjson.Set(result, path, op.Value)
+				if err != nil {
+					break
+				}
 			}
-			result, err = sjson.Set(result, opPath, op.Value)
 		case "move":
 			opFrom := processNegativeIndex(result, op.From)
 			opTo := processNegativeIndex(result, op.To)
@@ -508,27 +529,82 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 			opTo := processNegativeIndex(result, op.To)
 			result, err = copyValue(result, opFrom, opTo)
 		case "prepend":
-			result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
+			for _, path := range opPaths {
+				result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true)
+				if err != nil {
+					break
+				}
+			}
 		case "append":
-			result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
+			for _, path := range opPaths {
+				result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false)
+				if err != nil {
+					break
+				}
+			}
 		case "trim_prefix":
-			result, err = trimStringValue(result, opPath, op.Value, true)
+			for _, path := range opPaths {
+				result, err = trimStringValue(result, path, op.Value, true)
+				if err != nil {
+					break
+				}
+			}
 		case "trim_suffix":
-			result, err = trimStringValue(result, opPath, op.Value, false)
+			for _, path := range opPaths {
+				result, err = trimStringValue(result, path, op.Value, false)
+				if err != nil {
+					break
+				}
+			}
 		case "ensure_prefix":
-			result, err = ensureStringAffix(result, opPath, op.Value, true)
+			for _, path := range opPaths {
+				result, err = ensureStringAffix(result, path, op.Value, true)
+				if err != nil {
+					break
+				}
+			}
 		case "ensure_suffix":
-			result, err = ensureStringAffix(result, opPath, op.Value, false)
+			for _, path := range opPaths {
+				result, err = ensureStringAffix(result, path, op.Value, false)
+				if err != nil {
+					break
+				}
+			}
 		case "trim_space":
-			result, err = transformStringValue(result, opPath, strings.TrimSpace)
+			for _, path := range opPaths {
+				result, err = transformStringValue(result, path, strings.TrimSpace)
+				if err != nil {
+					break
+				}
+			}
 		case "to_lower":
-			result, err = transformStringValue(result, opPath, strings.ToLower)
+			for _, path := range opPaths {
+				result, err = transformStringValue(result, path, strings.ToLower)
+				if err != nil {
+					break
+				}
+			}
 		case "to_upper":
-			result, err = transformStringValue(result, opPath, strings.ToUpper)
+			for _, path := range opPaths {
+				result, err = transformStringValue(result, path, strings.ToUpper)
+				if err != nil {
+					break
+				}
+			}
 		case "replace":
-			result, err = replaceStringValue(result, opPath, op.From, op.To)
+			for _, path := range opPaths {
+				result, err = replaceStringValue(result, path, op.From, op.To)
+				if err != nil {
+					break
+				}
+			}
 		case "regex_replace":
-			result, err = regexReplaceStringValue(result, opPath, op.From, op.To)
+			for _, path := range opPaths {
+				result, err = regexReplaceStringValue(result, path, op.From, op.To)
+				if err != nil {
+					break
+				}
+			}
 		case "return_error":
 			returnErr, parseErr := parseParamOverrideReturnError(op.Value)
 			if parseErr != nil {
@@ -536,7 +612,12 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
 			}
 			return "", returnErr
 		case "prune_objects":
-			result, err = pruneObjects(result, opPath, contextJSON, op.Value)
+			for _, path := range opPaths {
+				result, err = pruneObjects(result, path, contextJSON, op.Value)
+				if err != nil {
+					break
+				}
+			}
 		case "set_header":
 			err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin)
 			if err == nil {
@@ -1174,6 +1255,92 @@ func copyValue(jsonStr, fromPath, toPath string) (string, error) {
 	return sjson.Set(jsonStr, toPath, sourceValue.Value())
 }
 
+func isPathBasedOperation(mode string) bool {
+	switch mode {
+	case "delete", "set", "prepend", "append", "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix", "trim_space", "to_lower", "to_upper", "replace", "regex_replace", "prune_objects":
+		return true
+	default:
+		return false
+	}
+}
+
+func resolveOperationPaths(jsonStr, path string) ([]string, error) {
+	if !strings.Contains(path, "*") {
+		return []string{path}, nil
+	}
+	return expandWildcardPaths(jsonStr, path)
+}
+
+func expandWildcardPaths(jsonStr, path string) ([]string, error) {
+	var root interface{}
+	if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
+		return nil, err
+	}
+
+	segments := strings.Split(path, ".")
+	paths := collectWildcardPaths(root, segments, nil)
+	return lo.Uniq(paths), nil
+}
+
+func collectWildcardPaths(node interface{}, segments []string, prefix []string) []string {
+	if len(segments) == 0 {
+		return []string{strings.Join(prefix, ".")}
+	}
+
+	segment := strings.TrimSpace(segments[0])
+	if segment == "" {
+		return nil
+	}
+	isLast := len(segments) == 1
+
+	if segment == "*" {
+		switch typed := node.(type) {
+		case map[string]interface{}:
+			keys := lo.Keys(typed)
+			sort.Strings(keys)
+			return lo.FlatMap(keys, func(key string, _ int) []string {
+				return collectWildcardPaths(typed[key], segments[1:], append(prefix, key))
+			})
+		case []interface{}:
+			return lo.FlatMap(lo.Range(len(typed)), func(index int, _ int) []string {
+				return collectWildcardPaths(typed[index], segments[1:], append(prefix, strconv.Itoa(index)))
+			})
+		default:
+			return nil
+		}
+	}
+
+	switch typed := node.(type) {
+	case map[string]interface{}:
+		if isLast {
+			return []string{strings.Join(append(prefix, segment), ".")}
+		}
+		next, exists := typed[segment]
+		if !exists {
+			return nil
+		}
+		return collectWildcardPaths(next, segments[1:], append(prefix, segment))
+	case []interface{}:
+		index, err := strconv.Atoi(segment)
+		if err != nil || index < 0 || index >= len(typed) {
+			return nil
+		}
+		if isLast {
+			return []string{strings.Join(append(prefix, segment), ".")}
+		}
+		return collectWildcardPaths(typed[index], segments[1:], append(prefix, segment))
+	default:
+		return nil
+	}
+}
+
+func deleteValue(jsonStr, path string) (string, error) {
+	if strings.TrimSpace(path) == "" {
+		return jsonStr, nil
+	}
+	return sjson.Delete(jsonStr, path)
+}
+
 func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
 	current := gjson.Get(jsonStr, path)
 	switch {

+ 256 - 0
relay/common/override_test.go

@@ -2,6 +2,7 @@ package common
 
 import (
 	"encoding/json"
+	"fmt"
 	"reflect"
 	"testing"
 
@@ -9,6 +10,7 @@ import (
 
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/setting/model_setting"
+	"github.com/samber/lo"
 )
 
 func TestApplyParamOverrideTrimPrefix(t *testing.T) {
@@ -242,6 +244,224 @@ func TestApplyParamOverrideDelete(t *testing.T) {
 	}
 }
 
+func TestApplyParamOverrideDeleteWildcardPath(t *testing.T) {
+	input := []byte(`{"tools":[{"type":"bash","custom":{"input_examples":["a"],"other":1}},{"type":"code","custom":{"input_examples":["b"]}},{"type":"noop","custom":{"other":2}}]}`)
+	override := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path": "tools.*.custom.input_examples",
+				"mode": "delete",
+			},
+		},
+	}
+
+	out, err := ApplyParamOverride(input, override, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride returned error: %v", err)
+	}
+	assertJSONEqual(t, `{"tools":[{"type":"bash","custom":{"other":1}},{"type":"code","custom":{}},{"type":"noop","custom":{"other":2}}]}`, string(out))
+}
+
+func TestApplyParamOverrideSetWildcardPath(t *testing.T) {
+	input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B"}},{"custom":{"tag":"C"}}]}`)
+	override := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path":  "tools.*.custom.enabled",
+				"mode":  "set",
+				"value": true,
+			},
+		},
+	}
+
+	out, err := ApplyParamOverride(input, override, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride returned error: %v", err)
+	}
+
+	var got struct {
+		Tools []struct {
+			Custom struct {
+				Enabled bool `json:"enabled"`
+			} `json:"custom"`
+		} `json:"tools"`
+	}
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatalf("failed to unmarshal output JSON: %v", err)
+	}
+
+	if !lo.EveryBy(got.Tools, func(item struct {
+		Custom struct {
+			Enabled bool `json:"enabled"`
+		} `json:"custom"`
+	}) bool {
+		return item.Custom.Enabled
+	}) {
+		t.Fatalf("expected wildcard set to enable all tools, got: %s", string(out))
+	}
+}
+
+func TestApplyParamOverrideTrimSpaceWildcardPath(t *testing.T) {
+	input := []byte(`{"tools":[{"custom":{"name":" alpha "}},{"custom":{"name":" beta"}},{"custom":{"name":"gamma "}}]}`)
+	override := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path": "tools.*.custom.name",
+				"mode": "trim_space",
+			},
+		},
+	}
+
+	out, err := ApplyParamOverride(input, override, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride returned error: %v", err)
+	}
+
+	var got struct {
+		Tools []struct {
+			Custom struct {
+				Name string `json:"name"`
+			} `json:"custom"`
+		} `json:"tools"`
+	}
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatalf("failed to unmarshal output JSON: %v", err)
+	}
+
+	names := lo.Map(got.Tools, func(item struct {
+		Custom struct {
+			Name string `json:"name"`
+		} `json:"custom"`
+	}, _ int) string {
+		return item.Custom.Name
+	})
+	if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
+		t.Fatalf("unexpected names after wildcard trim_space: %v", names)
+	}
+}
+
+func TestApplyParamOverrideDeleteWildcardEqualsIndexedPaths(t *testing.T) {
+	input := []byte(`{"tools":[{"custom":{"input_examples":["a"],"other":1}},{"custom":{"input_examples":["b"],"other":2}},{"custom":{"input_examples":["c"],"other":3}}]}`)
+
+	wildcardOverride := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path": "tools.*.custom.input_examples",
+				"mode": "delete",
+			},
+		},
+	}
+
+	indexedOverride := map[string]interface{}{
+		"operations": lo.Map(lo.Range(3), func(index int, _ int) interface{} {
+			return map[string]interface{}{
+				"path": fmt.Sprintf("tools.%d.custom.input_examples", index),
+				"mode": "delete",
+			}
+		}),
+	}
+
+	wildcardOut, err := ApplyParamOverride(input, wildcardOverride, nil)
+	if err != nil {
+		t.Fatalf("wildcard ApplyParamOverride returned error: %v", err)
+	}
+
+	indexedOut, err := ApplyParamOverride(input, indexedOverride, nil)
+	if err != nil {
+		t.Fatalf("indexed ApplyParamOverride returned error: %v", err)
+	}
+
+	assertJSONEqual(t, string(indexedOut), string(wildcardOut))
+}
+
+func TestApplyParamOverrideSetWildcardKeepOrigin(t *testing.T) {
+	input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B","enabled":false}},{"custom":{"tag":"C"}}]}`)
+	override := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path":        "tools.*.custom.enabled",
+				"mode":        "set",
+				"value":       true,
+				"keep_origin": true,
+			},
+		},
+	}
+
+	out, err := ApplyParamOverride(input, override, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride returned error: %v", err)
+	}
+
+	var got struct {
+		Tools []struct {
+			Custom struct {
+				Enabled bool `json:"enabled"`
+			} `json:"custom"`
+		} `json:"tools"`
+	}
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatalf("failed to unmarshal output JSON: %v", err)
+	}
+
+	enabledValues := lo.Map(got.Tools, func(item struct {
+		Custom struct {
+			Enabled bool `json:"enabled"`
+		} `json:"custom"`
+	}, _ int) bool {
+		return item.Custom.Enabled
+	})
+	if !reflect.DeepEqual(enabledValues, []bool{true, false, true}) {
+		t.Fatalf("unexpected enabled values after wildcard keep_origin set: %v", enabledValues)
+	}
+}
+
+func TestApplyParamOverrideTrimSpaceMultiWildcardPath(t *testing.T) {
+	input := []byte(`{"tools":[{"custom":{"items":[{"name":" alpha "},{"name":" beta "}]}},{"custom":{"items":[{"name":" gamma"}]}}]}`)
+	override := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path": "tools.*.custom.items.*.name",
+				"mode": "trim_space",
+			},
+		},
+	}
+
+	out, err := ApplyParamOverride(input, override, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride returned error: %v", err)
+	}
+
+	var got struct {
+		Tools []struct {
+			Custom struct {
+				Items []struct {
+					Name string `json:"name"`
+				} `json:"items"`
+			} `json:"custom"`
+		} `json:"tools"`
+	}
+	if err := json.Unmarshal(out, &got); err != nil {
+		t.Fatalf("failed to unmarshal output JSON: %v", err)
+	}
+
+	names := lo.FlatMap(got.Tools, func(tool struct {
+		Custom struct {
+			Items []struct {
+				Name string `json:"name"`
+			} `json:"items"`
+		} `json:"custom"`
+	}, _ int) []string {
+		return lo.Map(tool.Custom.Items, func(item struct {
+			Name string `json:"name"`
+		}, _ int) string {
+			return item.Name
+		})
+	})
+	if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
+		t.Fatalf("unexpected names after multi wildcard trim_space: %v", names)
+	}
+}
+
 func TestApplyParamOverrideSet(t *testing.T) {
 	input := []byte(`{"model":"gpt-4","temperature":0.7}`)
 	override := map[string]interface{}{
@@ -261,6 +481,42 @@ func TestApplyParamOverrideSet(t *testing.T) {
 	assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out))
 }
 
+func TestApplyParamOverrideSetWithDescriptionKeepsCompatibility(t *testing.T) {
+	input := []byte(`{"model":"gpt-4","temperature":0.7}`)
+	overrideWithoutDesc := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"path":  "temperature",
+				"mode":  "set",
+				"value": 0.1,
+			},
+		},
+	}
+	overrideWithDesc := map[string]interface{}{
+		"operations": []interface{}{
+			map[string]interface{}{
+				"description": "set temperature for deterministic output",
+				"path":        "temperature",
+				"mode":        "set",
+				"value":       0.1,
+			},
+		},
+	}
+
+	outWithoutDesc, err := ApplyParamOverride(input, overrideWithoutDesc, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride without description returned error: %v", err)
+	}
+
+	outWithDesc, err := ApplyParamOverride(input, overrideWithDesc, nil)
+	if err != nil {
+		t.Fatalf("ApplyParamOverride with description returned error: %v", err)
+	}
+
+	assertJSONEqual(t, string(outWithoutDesc), string(outWithDesc))
+	assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(outWithDesc))
+}
+
 func TestApplyParamOverrideSetKeepOrigin(t *testing.T) {
 	input := []byte(`{"model":"gpt-4","temperature":0.7}`)
 	override := map[string]interface{}{

+ 72 - 15
web/src/components/table/channels/modals/ParamOverrideEditorModal.jsx

@@ -276,6 +276,7 @@ const LEGACY_TEMPLATE = {
 const OPERATION_TEMPLATE = {
   operations: [
     {
+      description: 'Set default temperature for openai/* models.',
       path: 'temperature',
       mode: 'set',
       value: 0.7,
@@ -294,8 +295,9 @@ const OPERATION_TEMPLATE = {
 const HEADER_PASSTHROUGH_TEMPLATE = {
   operations: [
     {
+      description: 'Pass through X-Request-Id header to upstream.',
       mode: 'pass_headers',
-      value: ['Authorization'],
+      value: ['X-Request-Id'],
       keep_origin: true,
     },
   ],
@@ -304,6 +306,8 @@ const HEADER_PASSTHROUGH_TEMPLATE = {
 const GEMINI_IMAGE_4K_TEMPLATE = {
   operations: [
     {
+      description:
+        'Set imageSize to 4K when model contains gemini/image and ends with 4k.',
       mode: 'set',
       path: 'generationConfig.imageConfig.imageSize',
       value: '4K',
@@ -311,7 +315,17 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
         {
           path: 'original_model',
           mode: 'contains',
-          value: 'gemini-3-pro-image-preview',
+          value: 'gemini',
+        },
+        {
+          path: 'original_model',
+          mode: 'contains',
+          value: 'image',
+        },
+        {
+          path: 'original_model',
+          mode: 'suffix',
+          value: '4k',
         },
       ],
       logic: 'AND',
@@ -319,11 +333,13 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
   ],
 };
 
-const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
+const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
   operations: [
     {
+      description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.',
       mode: 'set_header',
       path: 'anthropic-beta',
+      // https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json
       value: {
         'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
         bash_20241022: null,
@@ -355,6 +371,11 @@ const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
         'web-search-2025-03-05': null,
       },
     },
+    {
+      description: 'Remove all tools[*].custom.input_examples before upstream relay.',
+      mode: 'delete',
+      path: 'tools.*.custom.input_examples',
+    },
   ],
 };
 
@@ -378,7 +399,7 @@ const TEMPLATE_PRESET_CONFIG = {
   },
   pass_headers_auth: {
     group: 'scenario',
-    label: '请求头透传(Authorization)',
+    label: '请求头透传(X-Request-Id)',
     kind: 'operations',
     payload: HEADER_PASSTHROUGH_TEMPLATE,
   },
@@ -402,9 +423,9 @@ const TEMPLATE_PRESET_CONFIG = {
   },
   aws_bedrock_anthropic_beta_override: {
     group: 'scenario',
-    label: 'AWS Bedrock anthropic-beta覆盖',
+    label: 'AWS Bedrock Claude 兼容模板',
     kind: 'operations',
-    payload: AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE,
+    payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE,
   },
 };
 
@@ -764,6 +785,7 @@ const createDefaultCondition = () => normalizeCondition({});
 
 const normalizeOperation = (operation = {}) => ({
   id: nextLocalId(),
+  description: typeof operation.description === 'string' ? operation.description : '',
   path: typeof operation.path === 'string' ? operation.path : '',
   mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
   value_text: toValueText(operation.value),
@@ -1086,6 +1108,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
     if (!keyword) return operations;
     return operations.filter((operation) => {
       const searchableText = [
+        operation.description,
         operation.mode,
         operation.path,
         operation.from,
@@ -1151,10 +1174,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
       const payloadOps = filteredOps.map((operation) => {
         const mode = operation.mode || 'set';
         const meta = MODE_META[mode] || MODE_META.set;
+        const descriptionValue = String(operation.description || '').trim();
         const pathValue = operation.path.trim();
         const fromValue = operation.from.trim();
         const toValue = operation.to.trim();
         const payload = { mode };
+        if (descriptionValue) {
+          payload.description = descriptionValue;
+        }
         if (meta.path) {
           payload.path = pathValue;
         }
@@ -1563,6 +1590,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
       if (index < 0) return prev;
       const source = prev[index];
       const cloned = normalizeOperation({
+        description: source.description,
         path: source.path,
         mode: source.mode,
         value: parseLooseValue(source.value_text),
@@ -1812,14 +1840,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
                 {t('重置')}
               </Button>
             </Space>
-            <Text
-              type='tertiary'
-              size='small'
-              className='cursor-pointer select-none mt-1 whitespace-nowrap'
-              onClick={() => openFieldGuide('path')}
-            >
-              {t('字段速查')}
-            </Text>
           </div>
         </Card>
 
@@ -1891,7 +1911,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
 
                       <Input
                         value={operationSearch}
-                        placeholder={t('搜索规则(类型 / 路径 / 来源 / 目标)')}
+                        placeholder={t('搜索规则(描述 / 类型 / 路径 / 来源 / 目标)')}
                         onChange={(nextValue) =>
                           setOperationSearch(nextValue || '')
                         }
@@ -1958,6 +1978,23 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
                                       >
                                         {getOperationSummary(operation, index)}
                                       </Text>
+                                      {String(operation.description || '').trim() ? (
+                                        <Text
+                                          type='tertiary'
+                                          size='small'
+                                          className='block mt-1'
+                                          style={{
+                                            lineHeight: 1.5,
+                                            wordBreak: 'break-word',
+                                            overflow: 'hidden',
+                                            display: '-webkit-box',
+                                            WebkitLineClamp: 2,
+                                            WebkitBoxOrient: 'vertical',
+                                          }}
+                                        >
+                                          {operation.description}
+                                        </Text>
+                                      ) : null}
                                     </div>
                                     <Tag size='small' color='grey'>
                                       {(operation.conditions || []).length}
@@ -2035,6 +2072,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
                                   type='danger'
                                   theme='borderless'
                                   icon={<IconDelete />}
+                                  aria-label={t('删除规则')}
                                   onClick={() =>
                                     removeOperation(selectedOperation.id)
                                   }
@@ -2085,6 +2123,25 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
                             >
                               {MODE_DESCRIPTIONS[mode] || ''}
                             </Text>
+                            <div className='mt-2'>
+                              <Text type='tertiary' size='small'>
+                                {t('规则描述(可选)')}
+                              </Text>
+                              <Input
+                                value={selectedOperation.description || ''}
+                                placeholder={t('例如:清理工具参数,避免上游校验错误')}
+                                onChange={(nextValue) =>
+                                  updateOperation(selectedOperation.id, {
+                                    description: nextValue || '',
+                                  })
+                                }
+                                maxLength={180}
+                                showClear
+                              />
+                              <Text type='tertiary' size='small' className='mt-1 block'>
+                                {`${String(selectedOperation.description || '').length}/180`}
+                              </Text>
+                            </div>
 
                             {meta.value ? (
                               mode === 'return_error' && returnErrorDraft ? (