- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
353 lines
14 KiB
Python
353 lines
14 KiB
Python
# modules/file_manager.py - 文件管理模块(添加行编辑功能)
|
||
|
||
import os
|
||
import shutil
|
||
from pathlib import Path
|
||
import re
|
||
from bisect import bisect_right
|
||
from typing import Any, Optional, Dict, List, Set, Tuple, TYPE_CHECKING
|
||
from datetime import datetime
|
||
try:
|
||
from config import (
|
||
MAX_FILE_SIZE,
|
||
FORBIDDEN_PATHS,
|
||
FORBIDDEN_ROOT_PATHS,
|
||
OUTPUT_FORMATS,
|
||
READ_TOOL_MAX_FILE_SIZE,
|
||
PROJECT_MAX_STORAGE_BYTES,
|
||
TERMINAL_SANDBOX_MODE,
|
||
LINUX_SAFETY,
|
||
)
|
||
except ImportError: # 兼容全局环境中存在同名包的情况
|
||
import sys
|
||
from pathlib import Path
|
||
project_root = Path(__file__).resolve().parents[1]
|
||
if str(project_root) not in sys.path:
|
||
sys.path.insert(0, str(project_root))
|
||
from config import (
|
||
MAX_FILE_SIZE,
|
||
FORBIDDEN_PATHS,
|
||
FORBIDDEN_ROOT_PATHS,
|
||
OUTPUT_FORMATS,
|
||
READ_TOOL_MAX_FILE_SIZE,
|
||
PROJECT_MAX_STORAGE_BYTES,
|
||
TERMINAL_SANDBOX_MODE,
|
||
LINUX_SAFETY,
|
||
)
|
||
from modules.container_file_proxy import ContainerFileProxy
|
||
from modules.host_sandbox_policy import get_macos_writable_paths, get_macos_readable_paths
|
||
from utils.logger import setup_logger
|
||
|
||
if TYPE_CHECKING:
|
||
from modules.user_container_manager import ContainerHandle
|
||
|
||
# 临时禁用长度检查
|
||
DISABLE_LENGTH_CHECK = True
|
||
|
||
logger = setup_logger(__name__)
|
||
|
||
class ReplaceMixin:
|
||
"""FileManager replace mixin 能力 mixin。"""
|
||
|
||
def replace_in_file(self, path: str, old_text: str, new_text: str, replace_all: bool = False) -> Dict:
|
||
"""替换文件中的内容"""
|
||
# 先读取文件
|
||
result = self.read_file(path)
|
||
if not result["success"]:
|
||
return result
|
||
|
||
content = result["content"]
|
||
|
||
# === 新增:替换操作的安全检查 ===
|
||
if old_text and len(old_text) > 9999999999:
|
||
return {
|
||
"success": False,
|
||
"error": "要替换的文本过长,可能导致性能问题",
|
||
"suggestion": "请拆分内容或使用 edit_file/逐块写入完成修改"
|
||
}
|
||
|
||
if new_text and len(new_text) > 9999999999:
|
||
return {
|
||
"success": False,
|
||
"error": "替换的新文本过长,建议分块处理",
|
||
"suggestion": "请将大内容分成多个小的替换操作"
|
||
}
|
||
short_old_text_notice = bool(old_text and len(old_text.splitlines()) < 3)
|
||
|
||
# 检查是否包含要替换的内容
|
||
if old_text and old_text not in content:
|
||
return {"success": False, "error": "未找到要替换的内容"}
|
||
|
||
matched_lines = self._find_match_line_numbers(content, old_text) if old_text else []
|
||
found_count = len(matched_lines) if old_text else 0
|
||
|
||
# 替换内容
|
||
if old_text:
|
||
if replace_all:
|
||
count = found_count
|
||
new_content = content.replace(old_text, new_text)
|
||
else:
|
||
count = 1
|
||
new_content = content.replace(old_text, new_text, 1)
|
||
else:
|
||
# 空文件直接写入新内容
|
||
new_content = new_text
|
||
count = 1
|
||
|
||
# 写回文件
|
||
result = self.write_file(path, new_content)
|
||
if result["success"]:
|
||
result["replacements"] = count
|
||
message_parts: List[str] = []
|
||
if old_text:
|
||
result["found_matches"] = found_count
|
||
result["matched_lines"] = matched_lines
|
||
if found_count > 1:
|
||
line_text = ",".join(str(line_no) for line_no in matched_lines)
|
||
message_parts.append(f"发现{found_count}处,于{line_text}行共替换{count}处")
|
||
if short_old_text_notice:
|
||
message_parts.insert(
|
||
0,
|
||
"提示:old_string 少于3行,已继续执行;需要批量替换的场景可以单行或不足一行"
|
||
)
|
||
if message_parts:
|
||
result["message"] = ";".join(message_parts)
|
||
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
|
||
|
||
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)。"""
|
||
if not target:
|
||
return []
|
||
newline_positions = [idx for idx, ch in enumerate(content) if ch == "\n"]
|
||
line_numbers: List[int] = []
|
||
search_start = 0
|
||
target_len = len(target)
|
||
|
||
while True:
|
||
idx = content.find(target, search_start)
|
||
if idx < 0:
|
||
break
|
||
line_no = bisect_right(newline_positions, idx) + 1
|
||
line_numbers.append(line_no)
|
||
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
|