feat(edit-file): add replace_all for precise or global replacement

This commit is contained in:
JOJO 2026-05-03 15:46:16 +08:00
parent 13689e80c2
commit 6aba89ae9b
8 changed files with 42 additions and 12 deletions

View File

@ -545,6 +545,12 @@ class MainTerminalToolsDefinitionMixin:
"new_string": { "new_string": {
"type": "string", "type": "string",
"description": "用于替换的新文本(必须不同于 old_string" "description": "用于替换的新文本(必须不同于 old_string"
},
"replace_all": {
"type": "boolean",
"enum": [True, False],
"description": "是否替换所有匹配内容。false=仅替换首个匹配true=替换全部匹配。",
"default": False
} }
}), }),
"required": ["file_path", "old_string", "new_string"] "required": ["file_path", "old_string", "new_string"]

View File

@ -1065,16 +1065,24 @@ class MainTerminalToolsExecutionMixin:
path = arguments.get("file_path") path = arguments.get("file_path")
old_text = arguments.get("old_string") old_text = arguments.get("old_string")
new_text = arguments.get("new_string") new_text = arguments.get("new_string")
replace_all = arguments.get("replace_all", False)
if not path: if not path:
result = {"success": False, "error": "缺少必要参数: file_path"} result = {"success": False, "error": "缺少必要参数: file_path"}
elif old_text is None or new_text is None: elif old_text is None or new_text is None:
result = {"success": False, "error": "缺少必要参数: old_string/new_string"} result = {"success": False, "error": "缺少必要参数: old_string/new_string"}
elif not isinstance(replace_all, bool):
result = {"success": False, "error": "replace_all 必须是 true 或 false"}
elif old_text == new_text: elif old_text == new_text:
result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"} result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"}
elif not old_text: elif not old_text:
result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"} result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"}
else: else:
result = self.file_manager.replace_in_file(path, old_text, new_text) result = self.file_manager.replace_in_file(
path,
old_text,
new_text,
replace_all=replace_all
)
elif tool_name == "create_folder": elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"]) result = self.file_manager.create_folder(arguments["path"])

View File

@ -45,6 +45,10 @@ function editFileTool(workspace, args) {
const target = resolvePath(workspace, args.file_path); const target = resolvePath(workspace, args.file_path);
const oldString = args.old_string ?? ''; const oldString = args.old_string ?? '';
const newString = args.new_string ?? ''; const newString = args.new_string ?? '';
const replaceAll = args.replace_all ?? false;
if (typeof replaceAll !== 'boolean') {
return { success: false, error: 'replace_all 必须是 true 或 false' };
}
const ctx = getContainerContext(); const ctx = getContainerContext();
if (ctx) { if (ctx) {
const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx); const resolved = resolveContainerPath(workspace, args.file_path || '.', ctx);
@ -70,8 +74,10 @@ function editFileTool(workspace, args) {
if (!original.includes(oldString)) { if (!original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' }; return { success: false, error: 'old_string 未匹配到内容' };
} }
const updated = original.split(oldString).join(newString); const replacements = replaceAll ? (original.split(oldString).length - 1) : 1;
const replacements = original.split(oldString).length - 1; const updated = replaceAll
? original.split(oldString).join(newString)
: original.replace(oldString, newString);
const writeResp = execContainerAction(ctx, 'write_file', { const writeResp = execContainerAction(ctx, 'write_file', {
path: rel, path: rel,
content: updated, content: updated,
@ -104,8 +110,10 @@ function editFileTool(workspace, args) {
if (!creating && !original.includes(oldString)) { if (!creating && !original.includes(oldString)) {
return { success: false, error: 'old_string 未匹配到内容' }; return { success: false, error: 'old_string 未匹配到内容' };
} }
const updated = creating ? newString : original.split(oldString).join(newString); const updated = creating
let replacements = creating ? 0 : original.split(oldString).length - 1; ? newString
: (replaceAll ? original.split(oldString).join(newString) : original.replace(oldString, newString));
let replacements = creating ? 0 : (replaceAll ? (original.split(oldString).length - 1) : 1);
if (creating && newString) replacements = 1; if (creating && newString) replacements = 1;
fs.writeFileSync(target, updated, 'utf8'); fs.writeFileSync(target, updated, 'utf8');
const diff = diffSummary(original, updated); const diff = diffSummary(original, updated);

View File

@ -1166,7 +1166,7 @@ class FileManager:
"error": write_error "error": write_error
} }
def replace_in_file(self, path: str, old_text: str, new_text: str) -> Dict: def replace_in_file(self, path: str, old_text: str, new_text: str, replace_all: bool = False) -> Dict:
"""替换文件中的内容""" """替换文件中的内容"""
# 先读取文件 # 先读取文件
result = self.read_file(path) result = self.read_file(path)
@ -1196,8 +1196,12 @@ class FileManager:
# 替换内容 # 替换内容
if old_text: if old_text:
new_content = content.replace(old_text, new_text) if replace_all:
count = content.count(old_text) count = content.count(old_text)
new_content = content.replace(old_text, new_text)
else:
count = 1
new_content = content.replace(old_text, new_text, 1)
else: else:
# 空文件直接写入新内容 # 空文件直接写入新内容
new_content = new_text new_content = new_text

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换 - `edit_file`:精确字符串替换`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配)
- `delete_file` / `rename_file` / `create_folder`:文件管理 - `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -87,7 +87,7 @@
- `create_file`:创建空文件(禁止在根目录创建,需先建子目录) - `create_file`:创建空文件(禁止在根目录创建,需先建子目录)
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)
- `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式) - `read_file`:读取文件(支持 `read`/`search`/`extract` 三种模式)
- `edit_file`:精确字符串替换 - `edit_file`:精确字符串替换`replace_all` 可选,默认 `false`,仅替换首个匹配;为 `true` 时替换全部匹配)
- `delete_file` / `rename_file` / `create_folder`:文件管理 - `delete_file` / `rename_file` / `create_folder`:文件管理
**注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。 **注意**:超大文件用 `search` 定位 + `extract` 抽取,避免全文读取。

View File

@ -80,7 +80,8 @@ def minimal_tool_definitions() -> List[Dict[str, Any]]:
"properties": { "properties": {
"file_path": {"type": "string", "description": "目标文件的相对路径"}, "file_path": {"type": "string", "description": "目标文件的相对路径"},
"old_string": {"type": "string", "description": "需要替换的原文"}, "old_string": {"type": "string", "description": "需要替换的原文"},
"new_string": {"type": "string", "description": "替换后的新内容"} "new_string": {"type": "string", "description": "替换后的新内容"},
"replace_all": {"type": "boolean", "description": "是否替换全部匹配", "default": False}
}, },
"required": ["file_path", "old_string", "new_string"] "required": ["file_path", "old_string", "new_string"]
} }

View File

@ -45,7 +45,9 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
file_path = args.get("file_path") file_path = args.get("file_path")
old_string = args.get("old_string") old_string = args.get("old_string")
new_string = args.get("new_string") new_string = args.get("new_string")
replace_all = args.get("replace_all", False)
preview["file_path"] = file_path preview["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path: if not file_path:
preview["summary"] = "缺少 file_path" preview["summary"] = "缺少 file_path"
return preview return preview
@ -82,7 +84,8 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
"old_start_line": start_line_no, "old_start_line": start_line_no,
"old_end_line": end_line_no, "old_end_line": end_line_no,
} }
preview["summary"] = f"编辑 {file_path}{start_line_no}-{end_line_no}" mode_text = "全部匹配" if replace_all is True else "首个匹配"
preview["summary"] = f"编辑 {file_path}{start_line_no}-{end_line_no} 行({mode_text}"
else: else:
preview["edit_context"] = { preview["edit_context"] = {
"before": [], "before": [],