feat(file-edit): support batched replacements
This commit is contained in:
parent
5bdc60006d
commit
6d5630c820
@ -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;
|
||||
}
|
||||
|
||||
@ -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_all;replace_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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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:
|
||||
"""清空文件内容"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>';
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user