feat(file-edit): support batched replacements

This commit is contained in:
JOJO 2026-05-31 13:15:37 +08:00
parent 5bdc60006d
commit 6d5630c820
8 changed files with 548 additions and 156 deletions

View File

@ -254,9 +254,7 @@ function editLines(args: any): string[] {
newLines.filter((line) => line.length > 0).forEach((line, idx) => result.push(`${idx + 1} +${line}`));
};
if (replacements.length) {
for (const rep of replacements) emitPair(String(rep.old_text || rep.old || ''), String(rep.new_text || rep.new || ''));
} else {
emitPair(String(args.old_string || ''), String(args.new_string || ''));
for (const rep of replacements) emitPair(String(rep.old_string || ''), String(rep.new_string || ''));
}
return result;
}

View File

@ -576,7 +576,7 @@ class MainTerminalToolsDefinitionMixin:
"type": "function",
"function": {
"name": "edit_file",
"description": "在文件中执行精确字符串替换;建议先使用 read_file 获取最新内容以确保精确匹配。",
"description": "在文件中按顺序执行一组或多组精确字符串替换;建议先使用 read_file 获取最新内容以确保精确匹配。任意一组失败时不会写入文件",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -584,21 +584,32 @@ class MainTerminalToolsDefinitionMixin:
"type": "string",
"description": "要修改文件的相对路径"
},
"old_string": {
"type": "string",
"description": "要替换的文本需与文件内容精确匹配保留缩进建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行"
},
"new_string": {
"type": "string",
"description": "用于替换的新文本(必须不同于 old_string"
},
"replace_all": {
"type": "boolean",
"enum": [True, False],
"description": "是否替换所有匹配内容必填。false=仅替换首个匹配true=替换全部匹配。"
"replacements": {
"type": "array",
"description": "替换项数组,按数组顺序依次执行。每一项包含 old_string/new_string可选 replace_allreplace_all 未提供时默认为 false。",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"old_string": {
"type": "string",
"description": "要替换的文本需与文件内容精确匹配保留缩进建议提供至少3行提升定位稳定性。需要批量替换的场景可以单行或不足一行"
},
"new_string": {
"type": "string",
"description": "用于替换的新文本(必须不同于 old_string"
},
"replace_all": {
"type": "boolean",
"enum": [True, False],
"description": "是否替换该 old_string 的所有匹配内容。false=仅替换首个匹配true=替换全部匹配;默认 false。"
}
},
"required": ["old_string", "new_string"]
}
}
}),
"required": ["file_path", "old_string", "new_string", "replace_all"]
"required": ["file_path", "replacements"]
}
}
},

View File

@ -1226,29 +1226,13 @@ class MainTerminalToolsExecutionMixin:
if read_guard_error:
return json.dumps(read_guard_error, ensure_ascii=False)
path = arguments.get("file_path")
old_text = arguments.get("old_string")
new_text = arguments.get("new_string")
replace_all_provided = "replace_all" in arguments
replace_all = arguments.get("replace_all") if replace_all_provided else None
replacements = arguments.get("replacements")
if not path:
result = {"success": False, "error": "缺少必要参数: file_path"}
elif old_text is None or new_text is None:
result = {"success": False, "error": "缺少必要参数: old_string/new_string"}
elif not replace_all_provided:
result = {"success": False, "error": "缺少必要参数: replace_all必须显式传入 true 或 false"}
elif not isinstance(replace_all, bool):
result = {"success": False, "error": "replace_all 必须是 true 或 false"}
elif old_text == new_text:
result = {"success": False, "error": "old_string 与 new_string 相同,无法执行替换"}
elif not old_text:
result = {"success": False, "error": "old_string 不能为空,请从 read_file 内容中精确复制"}
elif not isinstance(replacements, list) or not replacements:
result = {"success": False, "error": "缺少必要参数: replacements必须是非空数组"}
else:
result = self.file_manager.replace_in_file(
path,
old_text,
new_text,
replace_all=replace_all
)
result = self.file_manager.replace_many_in_file(path, replacements)
elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"])

View File

@ -5,7 +5,7 @@ import shutil
from pathlib import Path
import re
from bisect import bisect_right
from typing import Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from typing import Any, Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from datetime import datetime
try:
from config import (
@ -1311,6 +1311,177 @@ class FileManager:
return result
def replace_many_in_file(self, path: str, replacements: List[Dict[str, Any]]) -> Dict:
"""按顺序执行多组精确字符串替换;任意一组失败时不写入文件。"""
result = self.read_file(path)
if not result["success"]:
return result
if not isinstance(replacements, list) or not replacements:
return {"success": False, "error": "replacements 必须是非空数组"}
if len(replacements) > 100:
return {
"success": False,
"error": "replacements 数量过多,最多支持 100 组",
"suggestion": "请拆分为多次 edit_file 调用"
}
original_content = result["content"]
current_content = original_content
details: List[Dict[str, Any]] = []
failed_details: List[Dict[str, Any]] = []
total_replacements = 0
total_found_matches = 0
short_old_text_indices: List[int] = []
for zero_based_index, item in enumerate(replacements):
index = zero_based_index + 1
detail: Dict[str, Any] = {
"index": index,
"status": "pending",
"replace_all": False,
"found_matches": 0,
"replacements": 0,
"matched_lines": [],
}
if not isinstance(item, dict):
detail.update({
"status": "error",
"reason": "替换项必须是对象,包含 old_string 和 new_string"
})
failed_details.append(detail.copy())
details.append(detail)
break
old_text = item.get("old_string")
new_text = item.get("new_string")
replace_all = item.get("replace_all", False)
detail["replace_all"] = replace_all
if old_text is None or new_text is None:
detail.update({
"status": "error",
"reason": "缺少 old_string/new_string"
})
failed_details.append(detail.copy())
details.append(detail)
break
if not isinstance(old_text, str) or not isinstance(new_text, str):
detail.update({
"status": "error",
"reason": "old_string/new_string 必须是字符串"
})
failed_details.append(detail.copy())
details.append(detail)
break
if not isinstance(replace_all, bool):
detail.update({
"status": "error",
"reason": "replace_all 必须是 true 或 false不提供时默认为 false"
})
failed_details.append(detail.copy())
details.append(detail)
break
if old_text == new_text:
detail.update({
"status": "error",
"reason": "old_string 与 new_string 相同,无法执行替换"
})
failed_details.append(detail.copy())
details.append(detail)
break
if not old_text:
detail.update({
"status": "error",
"reason": "old_string 不能为空,请从 read_file 内容中精确复制"
})
failed_details.append(detail.copy())
details.append(detail)
break
if len(old_text) > 9999999999:
detail.update({
"status": "error",
"reason": "要替换的文本过长,可能导致性能问题"
})
failed_details.append(detail.copy())
details.append(detail)
break
if len(new_text) > 9999999999:
detail.update({
"status": "error",
"reason": "替换的新文本过长,建议分块处理"
})
failed_details.append(detail.copy())
details.append(detail)
break
if len(old_text.splitlines()) < 3:
short_old_text_indices.append(index)
matched_lines = self._find_match_line_numbers(current_content, old_text)
found_count = len(matched_lines)
if found_count == 0:
detail.update({
"status": "not_found",
"reason": "未找到要替换的内容"
})
failed_details.append(detail.copy())
details.append(detail)
break
replacement_count = found_count if replace_all else 1
contexts = self._find_match_contexts(
current_content,
old_text,
max_matches=replacement_count
)
if replace_all:
current_content = current_content.replace(old_text, new_text)
else:
current_content = current_content.replace(old_text, new_text, 1)
detail.update({
"status": "success",
"found_matches": found_count,
"replacements": replacement_count,
"matched_lines": matched_lines,
"contexts": contexts,
})
details.append(detail)
total_found_matches += found_count
total_replacements += replacement_count
if failed_details:
return {
"success": False,
"path": result.get("path") or path,
"error": f"{failed_details[0].get('index')} 组替换失败:{failed_details[0].get('reason')}",
"failed_replacement_index": failed_details[0].get("index"),
"failed_details": failed_details,
"details": details,
"write_performed": False,
"completed_replacements": len([item for item in details if item.get("status") == "success"]),
}
write_result = self.write_file(path, current_content)
if write_result["success"]:
write_result["replacements"] = total_replacements
write_result["found_matches"] = total_found_matches
write_result["replacement_groups"] = len(replacements)
write_result["details"] = details
write_result["write_performed"] = True
message_parts: List[str] = [f"{len(replacements)} 组替换,替换 {total_replacements}"]
if short_old_text_indices:
indices_text = ",".join(str(item) for item in short_old_text_indices)
message_parts.append(f"提示:第 {indices_text} 组 old_string 少于3行已继续执行")
write_result["message"] = "".join(message_parts)
print(f"{OUTPUT_FORMATS['file']} 批量替换了 {total_replacements} 处内容")
return write_result
@staticmethod
def _find_match_line_numbers(content: str, target: str) -> List[int]:
"""返回 target 在 content 中每个匹配起始位置对应的行号1-based"""
@ -1330,6 +1501,50 @@ class FileManager:
search_start = idx + max(1, target_len)
return line_numbers
@staticmethod
def _find_match_contexts(content: str, target: str, max_matches: Optional[int] = None) -> List[Dict[str, Any]]:
"""返回每个实际替换匹配位置的上下文行,仅用于结果 metadata 展示。"""
if not target:
return []
lines = content.splitlines()
newline_positions = [idx for idx, ch in enumerate(content) if ch == "\n"]
contexts: List[Dict[str, Any]] = []
search_start = 0
target_len = len(target)
old_line_count = max(1, len(target.splitlines()))
while True:
if max_matches is not None and len(contexts) >= max_matches:
break
idx = content.find(target, search_start)
if idx < 0:
break
start_line = bisect_right(newline_positions, idx) + 1
end_line = start_line + old_line_count - 1
before_start = max(1, start_line - 2)
before_lines = [
{"line": line_no, "text": lines[line_no - 1]}
for line_no in range(before_start, start_line)
if 0 <= line_no - 1 < len(lines)
]
after_end = min(len(lines), end_line + 2)
after_lines = [
{"line": line_no, "text": lines[line_no - 1]}
for line_no in range(end_line + 1, after_end + 1)
if 0 <= line_no - 1 < len(lines)
]
contexts.append({
"old_start_line": start_line,
"old_end_line": end_line,
"before_lines": before_lines,
"after_lines": after_lines,
})
search_start = idx + max(1, target_len)
return contexts
def clear_file(self, path: str) -> Dict:
"""清空文件内容"""

View File

@ -47,28 +47,15 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
if function_name == "edit_file":
file_path = args.get("file_path")
old_string = args.get("old_string")
new_string = args.get("new_string")
replace_all_provided = "replace_all" in args
replace_all = args.get("replace_all") if replace_all_provided else None
replacements = args.get("replacements")
preview["file_path"] = file_path
preview["replace_all"] = replace_all
if not file_path:
preview["summary"] = "缺少 file_path"
return preview
if old_string is None or new_string is None:
preview["summary"] = "缺少 old_string/new_string"
if not isinstance(replacements, list) or not replacements:
preview["summary"] = "缺少 replacements必须是非空数组"
return preview
if not replace_all_provided:
preview["summary"] = "缺少 replace_all必须显式传入 true 或 false"
return preview
if not isinstance(replace_all, bool):
preview["summary"] = "replace_all 必须是 true 或 false"
return preview
old_text = str(old_string or "")
short_old_text_notice = len(old_text.splitlines()) < 3
if short_old_text_notice:
preview["notice"] = "提示old_string 少于3行允许继续执行需要批量替换的场景可以单行或不足一行"
preview["replacement_groups"] = len(replacements)
try:
valid, err, full_path = web_terminal.file_manager._validate_path(str(file_path))
if not valid or full_path is None:
@ -80,7 +67,16 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
preview["summary"] = "目标文件不存在,无法生成上下文预览"
return preview
content = full_path.read_text(encoding="utf-8", errors="ignore")
new_text = str(new_string or "")
first = replacements[0] if isinstance(replacements[0], dict) else {}
old_text = str(first.get("old_string") or "")
new_text = str(first.get("new_string") or "")
replace_all = first.get("replace_all", False)
if not isinstance(replace_all, bool):
preview["summary"] = "第 1 组 replace_all 必须是 true 或 false"
return preview
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
if short_old_text_notice:
preview["notice"] = "提示:第 1 组 old_string 少于3行允许继续执行需要批量替换的场景可以单行或不足一行"
old_lines = old_text.splitlines()
new_lines = new_text.splitlines()
idx = content.find(old_text) if old_text else -1
@ -102,7 +98,7 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
"old_end_line": end_line_no,
}
mode_text = "全部匹配" if replace_all is True else "首个匹配"
preview["summary"] = f"编辑 {file_path}{start_line_no}-{end_line_no} 行({mode_text}"
preview["summary"] = f"编辑 {file_path},共 {len(replacements)} 组;预览第 1 组{start_line_no}-{end_line_no} 行({mode_text}"
else:
preview["edit_context"] = {
"before": [],
@ -112,9 +108,9 @@ def _build_tool_approval_preview(web_terminal, function_name: str, arguments: Di
"old_start_line": None,
"old_end_line": None,
}
preview["summary"] = "未在文件中定位到 old_string显示原始替换内容"
preview["summary"] = f"{len(replacements)} 组替换;未在文件中定位到第 1 组 old_string显示原始替换内容"
if short_old_text_notice:
preview["summary"] = f"{preview['summary']}old_string 少于3行已告知并继续"
preview["summary"] = f"{preview['summary']}第 1 组 old_string 少于3行已告知并继续"
except Exception as exc:
preview["summary"] = f"生成编辑预览失败: {exc}"
return preview

View File

@ -318,8 +318,6 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑');
const replaceAllValue =
typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无';
const foundCount =
typeof result.found_matches === 'number'
? result.found_matches
@ -328,17 +326,12 @@ function renderEditFile(result: any, args: any): string {
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
//
// 1. args.replacements
// 2. args.old_string args.new_string
const replacements = args.replacements || [];
let oldString = args.old_string || '';
let newString = args.new_string || '';
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? replacements.length) || '无'}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
@ -350,53 +343,116 @@ function renderEditFile(result: any, args: any): string {
return html;
}
// replacements 使
if (replacements.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const details = Array.isArray(result.details) ? result.details : [];
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
replacements.forEach((rep: any, idx: number) => {
if (idx > 0) html += '<div class="diff-separator">⋮</div>';
const oldText = rep.old_string || '';
const newText = rep.new_string || '';
const detail = details[idx] || {};
const contexts = Array.isArray(detail.contexts) ? detail.contexts : [];
const buildPairRows = (context?: any) => {
const rows: any[] = [];
const startLineCandidate = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const startLine = Number.isFinite(startLineCandidate) ? startLineCandidate : null;
if (oldText) {
const oldLines = oldText.split('\n');
oldLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '-', text: line, className: 'diff-remove' });
});
}
const oldText = rep.old_text || rep.old || '';
const newText = rep.new_text || rep.new || '';
if (newText) {
const newLines = newText.split('\n');
newLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '+', text: line, className: 'diff-add' });
});
}
return rows;
};
if (oldText) {
const oldLines = oldText.split('\n');
oldLines.forEach((line: string) => {
html += `<div class="diff-line diff-remove">- ${escapeHtml(line)}</div>`;
if (contexts.length > 0) {
contexts.forEach((context: any) => {
const rows: any[] = [];
const rawBeforeLines = Array.isArray(context.before_lines) ? context.before_lines : [];
const rawAfterLines = Array.isArray(context.after_lines) ? context.after_lines : [];
const beforeLines =
rawBeforeLines.length >= 2 && rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
const afterLines =
rawAfterLines.length >= 2 && rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawAfterLines.length >= 2 && String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
beforeLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
});
rows.push(...buildPairRows(context));
afterLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
});
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
});
});
}
if (newText) {
const newLines = newText.split('\n');
newLines.forEach((line: string) => {
html += `<div class="diff-line diff-add">+ ${escapeHtml(line)}</div>`;
} else {
const rows = buildPairRows();
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
});
}
});
html += '</div>';
}
// 使old_string/new_string
else if (oldString || newString) {
// \n
oldString = oldString.replace(/\\n/g, '\n');
newString = newString.replace(/\\n/g, '\n');
html += '<div class="tool-result-diff scrollable">';
if (oldString) {
const oldLines = oldString.split('\n');
oldLines.forEach((line: string) => {
html += `<div class="diff-line diff-remove">- ${escapeHtml(line)}</div>`;
});
}
if (newString) {
const newLines = newString.split('\n');
newLines.forEach((line: string) => {
html += `<div class="diff-line diff-add">+ ${escapeHtml(line)}</div>`;
});
}
const sortedHunks = hunks.sort((a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER));
const mergedHunks: any[] = [];
sortedHunks.forEach((hunk) => {
const last = mergedHunks[mergedHunks.length - 1];
if (last && hunk.minLine !== null && last.maxLine !== null && hunk.minLine <= last.maxLine + 1) {
last.rows.push(...hunk.rows);
last.maxLine = Math.max(last.maxLine, hunk.maxLine ?? last.maxLine);
} else {
mergedHunks.push({ ...hunk, rows: [...hunk.rows] });
}
});
const markerOrder: Record<string, number> = { ' ': 0, '-': 1, '+': 2 };
mergedHunks.forEach((hunk, hunkIdx) => {
if (hunkIdx > 0) renderDiffLine(null, ' ', '⋮', 'diff-separator');
const changedLines = new Set(
hunk.rows
.filter((row: any) => (row.marker === '-' || row.marker === '+') && typeof row.lineNo === 'number')
.map((row: any) => row.lineNo)
);
const seenContextLines = new Set<number>();
hunk.rows
.filter((row: any) => !(row.marker === ' ' && changedLines.has(row.lineNo)))
.filter((row: any) => {
if (row.marker !== ' ' || typeof row.lineNo !== 'number') return true;
if (seenContextLines.has(row.lineNo)) return false;
seenContextLines.add(row.lineNo);
return true;
})
.sort((a: any, b: any) => {
const lineDelta = (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return lineDelta || (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
})
.forEach((row: any) => renderDiffLine(row.lineNo, row.marker, row.text, row.className));
});
html += '</div>';
}
@ -1142,8 +1198,28 @@ function renderEasterEgg(result: any, args: any): string {
}
.diff-line {
display: grid;
grid-template-columns: 5ch 1ch minmax(0, 1fr);
column-gap: 8px;
padding: 2px 4px;
margin: 1px 0;
align-items: start;
}
.diff-line-number {
color: var(--claude-text-tertiary);
text-align: right;
user-select: none;
}
.diff-marker {
color: var(--claude-text-secondary);
text-align: center;
user-select: none;
}
.diff-content {
min-width: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
@ -1159,11 +1235,17 @@ function renderEasterEgg(result: any, args: any): string {
}
.diff-separator {
text-align: center;
color: rgba(0, 0, 0, 0.3);
text-align: left;
color: var(--claude-text-tertiary);
margin: 8px 0;
}
.diff-separator .diff-content {
font-size: 16px;
font-weight: 700;
line-height: 1.1;
}
.diff-operation {
font-weight: 600;
margin: 8px 0 4px;

View File

@ -258,8 +258,6 @@ function renderWriteFile(result: any, args: any): string {
function renderEditFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已编辑');
const replaceAllValue =
typeof args.replace_all === 'boolean' ? (args.replace_all ? '开' : '关') : '无';
const foundCount =
typeof result.found_matches === 'number'
? result.found_matches
@ -268,17 +266,12 @@ function renderEditFile(result: any, args: any): string {
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
// 兼容两种数据格式:
// 1. 新格式args.replacements 数组
// 2. 旧格式args.old_string 和 args.new_string
const replacements = args.replacements || [];
let oldString = args.old_string || '';
let newString = args.new_string || '';
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换全部:</strong>${replaceAllValue}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? replacements.length) || '无'}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
@ -290,53 +283,116 @@ function renderEditFile(result: any, args: any): string {
return html;
}
// 如果有 replacements 数组,使用新格式
if (replacements.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const details = Array.isArray(result.details) ? result.details : [];
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
replacements.forEach((rep: any, idx: number) => {
if (idx > 0) html += '<div class="diff-separator">⋮</div>';
const oldText = rep.old_string || '';
const newText = rep.new_string || '';
const detail = details[idx] || {};
const contexts = Array.isArray(detail.contexts) ? detail.contexts : [];
const buildPairRows = (context?: any) => {
const rows: any[] = [];
const startLineCandidate = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const startLine = Number.isFinite(startLineCandidate) ? startLineCandidate : null;
if (oldText) {
const oldLines = oldText.split('\n');
oldLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '-', text: line, className: 'diff-remove' });
});
}
const oldText = rep.old_text || rep.old || '';
const newText = rep.new_text || rep.new || '';
if (newText) {
const newLines = newText.split('\n');
newLines.forEach((line: string, lineIdx: number) => {
rows.push({ lineNo: startLine === null ? null : startLine + lineIdx, marker: '+', text: line, className: 'diff-add' });
});
}
return rows;
};
if (oldText) {
const oldLines = oldText.split('\n');
oldLines.forEach((line: string) => {
html += `<div class="diff-line diff-remove">- ${escapeHtml(line)}</div>`;
if (contexts.length > 0) {
contexts.forEach((context: any) => {
const rows: any[] = [];
const rawBeforeLines = Array.isArray(context.before_lines) ? context.before_lines : [];
const rawAfterLines = Array.isArray(context.after_lines) ? context.after_lines : [];
const beforeLines =
rawBeforeLines.length >= 2 && rawBeforeLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawBeforeLines.length >= 2 && String(rawBeforeLines[0]?.text ?? '') === ''
? rawBeforeLines.slice(1)
: rawBeforeLines;
const afterLines =
rawAfterLines.length >= 2 && rawAfterLines.every((item: any) => String(item?.text ?? '') === '')
? []
:
rawAfterLines.length >= 2 && String(rawAfterLines[rawAfterLines.length - 1]?.text ?? '') === ''
? rawAfterLines.slice(0, -1)
: rawAfterLines;
beforeLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
});
rows.push(...buildPairRows(context));
afterLines.forEach((item: any) => {
rows.push({ lineNo: Number(item.line), marker: ' ', text: String(item.text ?? ''), className: '' });
});
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
});
});
}
if (newText) {
const newLines = newText.split('\n');
newLines.forEach((line: string) => {
html += `<div class="diff-line diff-add">+ ${escapeHtml(line)}</div>`;
} else {
const rows = buildPairRows();
const numberedRows = rows.filter((row) => typeof row.lineNo === 'number' && Number.isFinite(row.lineNo));
hunks.push({
rows,
minLine: numberedRows.length ? Math.min(...numberedRows.map((row) => row.lineNo)) : null,
maxLine: numberedRows.length ? Math.max(...numberedRows.map((row) => row.lineNo)) : null,
});
}
});
html += '</div>';
}
// 否则使用旧格式old_string/new_string
else if (oldString || newString) {
// 处理转义的 \n
oldString = oldString.replace(/\\n/g, '\n');
newString = newString.replace(/\\n/g, '\n');
html += '<div class="tool-result-diff scrollable">';
if (oldString) {
const oldLines = oldString.split('\n');
oldLines.forEach((line: string) => {
html += `<div class="diff-line diff-remove">- ${escapeHtml(line)}</div>`;
});
}
if (newString) {
const newLines = newString.split('\n');
newLines.forEach((line: string) => {
html += `<div class="diff-line diff-add">+ ${escapeHtml(line)}</div>`;
});
}
const sortedHunks = hunks.sort((a, b) => (a.minLine ?? Number.MAX_SAFE_INTEGER) - (b.minLine ?? Number.MAX_SAFE_INTEGER));
const mergedHunks: any[] = [];
sortedHunks.forEach((hunk) => {
const last = mergedHunks[mergedHunks.length - 1];
if (last && hunk.minLine !== null && last.maxLine !== null && hunk.minLine <= last.maxLine + 1) {
last.rows.push(...hunk.rows);
last.maxLine = Math.max(last.maxLine, hunk.maxLine ?? last.maxLine);
} else {
mergedHunks.push({ ...hunk, rows: [...hunk.rows] });
}
});
const markerOrder: Record<string, number> = { ' ': 0, '-': 1, '+': 2 };
mergedHunks.forEach((hunk, hunkIdx) => {
if (hunkIdx > 0) renderDiffLine(null, ' ', '⋮', 'diff-separator');
const changedLines = new Set(
hunk.rows
.filter((row: any) => (row.marker === '-' || row.marker === '+') && typeof row.lineNo === 'number')
.map((row: any) => row.lineNo)
);
const seenContextLines = new Set<number>();
hunk.rows
.filter((row: any) => !(row.marker === ' ' && changedLines.has(row.lineNo)))
.filter((row: any) => {
if (row.marker !== ' ' || typeof row.lineNo !== 'number') return true;
if (seenContextLines.has(row.lineNo)) return false;
seenContextLines.add(row.lineNo);
return true;
})
.sort((a: any, b: any) => {
const lineDelta = (a.lineNo ?? Number.MAX_SAFE_INTEGER) - (b.lineNo ?? Number.MAX_SAFE_INTEGER);
return lineDelta || (markerOrder[a.marker] ?? 9) - (markerOrder[b.marker] ?? 9);
})
.forEach((row: any) => renderDiffLine(row.lineNo, row.marker, row.text, row.className));
});
html += '</div>';
}

View File

@ -493,12 +493,62 @@ def _format_write_file(result_data: Dict[str, Any]) -> str:
def _format_edit_file(result_data: Dict[str, Any]) -> str:
if not result_data.get("success"):
return _format_failure("edit_file", result_data)
base = _format_failure("edit_file", result_data)
failed_details = result_data.get("failed_details")
details = result_data.get("details")
detail_lines: List[str] = []
if isinstance(failed_details, list) and failed_details:
for item in failed_details[:5]:
if not isinstance(item, dict):
continue
index = item.get("index")
reason = item.get("reason") or item.get("error") or "未知原因"
status = item.get("status")
prefix = f"{index}" if index is not None else "某一组"
if status:
detail_lines.append(f"{prefix}失败({status}{reason}")
else:
detail_lines.append(f"{prefix}失败:{reason}")
if isinstance(details, list):
completed = [
item for item in details
if isinstance(item, dict) and item.get("status") == "success"
]
if completed:
detail_lines.append(
f"失败前已在内存中完成 {len(completed)} 组预处理,但因后续失败未写入文件。"
)
if result_data.get("write_performed") is False:
detail_lines.append("文件未写入。")
if detail_lines:
return base + "\n" + "\n".join(detail_lines)
return base
path = result_data.get("path") or "目标文件"
count = result_data.get("replacements")
groups = result_data.get("replacement_groups")
found = result_data.get("found_matches")
msg = result_data.get("message")
replaced_note = f"替换 {count}" if isinstance(count, int) else "已完成替换"
if isinstance(groups, int):
replaced_note = f"完成 {groups} 组替换,{replaced_note}"
parts = [f"{replaced_note}: {path}"]
if isinstance(found, int):
parts.append(f"累计找到 {found} 处匹配")
details = result_data.get("details")
if isinstance(details, list) and details:
group_summaries: List[str] = []
for item in details[:10]:
if not isinstance(item, dict):
continue
index = item.get("index")
item_found = item.get("found_matches")
item_replaced = item.get("replacements")
replace_all = item.get("replace_all")
mode = "全部匹配" if replace_all is True else "首个匹配"
if index is not None and isinstance(item_found, int) and isinstance(item_replaced, int):
group_summaries.append(f"{index}{mode}:找到 {item_found} 处,替换 {item_replaced}")
if group_summaries:
parts.append("".join(group_summaries))
if msg:
parts.append(str(msg))
return "".join(parts)