# 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 ReadMixin: """FileManager read mixin 能力 mixin。""" def _read_text_lines( self, full_path: Path, *, size_limit: Optional[int] = None, encoding: str = "utf-8", ) -> Dict: """读取UTF-8文本并返回行列表。""" try: file_size = full_path.stat().st_size except FileNotFoundError: return {"success": False, "error": "文件不存在"} if size_limit and file_size > size_limit: return { "success": False, "error": f"文件太大 ({file_size / 1024 / 1024:.2f}MB > {size_limit / 1024 / 1024}MB)" } try: with open(full_path, 'r', encoding=encoding) as f: lines = f.readlines() except UnicodeDecodeError: return { "success": False, "error": "文件不是 UTF-8 文本,无法直接读取,请改用 run_command 调用合适的解析工具或 Python 解释器。" } except Exception as e: return {"success": False, "error": f"读取文件失败: {e}"} content = "".join(lines) return { "success": True, "content": content, "lines": lines, "size": file_size } def read_file(self, path: str) -> Dict: """读取文件内容(兼容旧逻辑,限制为 MAX_FILE_SIZE)。""" valid, error, full_path = self._validate_path(path) if not valid: return {"success": False, "error": error} if not full_path.exists(): return {"success": False, "error": "文件不存在"} if not full_path.is_file(): return {"success": False, "error": "不是文件"} ok, msg = self._ensure_host_access(full_path, "read") if not ok: return {"success": False, "error": msg} if self._use_container(): relative_path = self._relative_path(full_path) result = self._container_call("read_file", { "path": relative_path, "size_limit": MAX_FILE_SIZE }) return result result = self._read_text_lines(full_path, size_limit=MAX_FILE_SIZE) if not result["success"]: return result relative_path = self._relative_path(full_path) return { "success": True, "path": relative_path, "content": result["content"], "size": result["size"] } def read_text_segment( self, path: str, *, start_line: Optional[int] = None, end_line: Optional[int] = None, size_limit: Optional[int] = None ) -> Dict: """按行范围读取文本片段。""" valid, error, full_path = self._validate_path(path) if not valid: return {"success": False, "error": error} if not full_path.exists(): return {"success": False, "error": "文件不存在"} if not full_path.is_file(): return {"success": False, "error": "不是文件"} ok, msg = self._ensure_host_access(full_path, "read") if not ok: return {"success": False, "error": msg} if self._use_container(): relative_path = self._relative_path(full_path) result = self._container_call("read_text_segment", { "path": relative_path, "start_line": start_line, "end_line": end_line, "size_limit": size_limit or READ_TOOL_MAX_FILE_SIZE }) return result if self._use_container(): relative_path = self._relative_path(full_path) return self._container_call("search_text", { "path": relative_path, "query": query, "max_matches": max_matches, "context_before": context_before, "context_after": context_after, "case_sensitive": case_sensitive, }) if self._use_container(): relative_path = self._relative_path(full_path) return self._container_call("extract_segments", { "path": relative_path, "segments": segments, "size_limit": size_limit or READ_TOOL_MAX_FILE_SIZE }) result = self._read_text_lines( full_path, size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE ) if not result["success"]: return result lines = result["lines"] total_lines = len(lines) if total_lines == 0: relative_path = self._relative_path(full_path) return { "success": True, "path": relative_path, "content": "", "size": result["size"], "line_start": 0, "line_end": 0, "total_lines": 0, } start = start_line if start_line and start_line > 0 else 1 end = end_line if end_line and end_line >= start else total_lines if start > total_lines: return {"success": False, "error": "起始行超出文件长度"} end = min(end, total_lines) selected_lines = lines[start - 1 : end] content = "".join(selected_lines) relative_path = self._relative_path(full_path) return { "success": True, "path": relative_path, "content": content, "size": result["size"], "line_start": start, "line_end": end, "total_lines": total_lines } def search_text( self, path: str, *, query: str, max_matches: int, context_before: int, context_after: int, case_sensitive: bool = False, size_limit: Optional[int] = None ) -> Dict: """在文件中搜索关键词,返回合并后的窗口。""" if not query: return {"success": False, "error": "缺少搜索关键词"} valid, error, full_path = self._validate_path(path) if not valid: return {"success": False, "error": error} if not full_path.exists(): return {"success": False, "error": "文件不存在"} if not full_path.is_file(): return {"success": False, "error": "不是文件"} ok, msg = self._ensure_host_access(full_path, "read") if not ok: return {"success": False, "error": msg} result = self._read_text_lines( full_path, size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE ) if not result["success"]: return result lines = result["lines"] total_lines = len(lines) matches = [] query_text = query if case_sensitive else query.lower() def contains(haystack: str) -> bool: target = haystack if case_sensitive else haystack.lower() return query_text in target for idx, line in enumerate(lines, start=1): if contains(line): window_start = max(1, idx - context_before) window_end = min(total_lines, idx + context_after) if matches and window_start <= matches[-1]["line_end"]: matches[-1]["line_end"] = max(matches[-1]["line_end"], window_end) matches[-1]["hits"].append(idx) else: if len(matches) >= max_matches: break matches.append({ "line_start": window_start, "line_end": window_end, "hits": [idx] }) relative_path = self._relative_path(full_path) for window in matches: snippet_lines = lines[window["line_start"] - 1 : window["line_end"]] window["snippet"] = "".join(snippet_lines) return { "success": True, "path": relative_path, "size": result["size"], "total_lines": total_lines, "matches": matches } def extract_segments( self, path: str, segments: List[Dict], *, size_limit: Optional[int] = None ) -> Dict: """根据多个行区间提取内容。""" if not segments: return {"success": False, "error": "缺少要提取的行区间"} valid, error, full_path = self._validate_path(path) if not valid: return {"success": False, "error": error} if not full_path.exists(): return {"success": False, "error": "文件不存在"} if not full_path.is_file(): return {"success": False, "error": "不是文件"} ok, msg = self._ensure_host_access(full_path, "read") if not ok: return {"success": False, "error": msg} result = self._read_text_lines( full_path, size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE ) if not result["success"]: return result lines = result["lines"] total_lines = len(lines) extracted = [] for item in segments: if not isinstance(item, dict): return {"success": False, "error": "segments 数组中的每一项都必须是对象"} start_line = item.get("start_line") end_line = item.get("end_line") label = item.get("label") if start_line is None or end_line is None: return {"success": False, "error": "所有区间都必须包含 start_line 和 end_line"} if start_line <= 0 or end_line < start_line: return {"success": False, "error": "行区间不合法"} if start_line > total_lines: return {"success": False, "error": f"区间起点 {start_line} 超出文件行数"} end_line = min(end_line, total_lines) snippet = "".join(lines[start_line - 1 : end_line]) extracted.append({ "label": label, "line_start": start_line, "line_end": end_line, "content": snippet }) relative_path = self._relative_path(full_path) return { "success": True, "path": relative_path, "size": result["size"], "total_lines": total_lines, "segments": extracted }