mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""
|
||
文件读取工具
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any, Optional, Type, cast
|
||
|
||
from anyio import Path as AsyncPath
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.agent.tools.base import MoviePilotTool
|
||
from app.agent.tools.tags import ToolTag
|
||
from app.runtime.log import logger
|
||
|
||
# 最大读取大小 50KB
|
||
MAX_READ_SIZE = 50 * 1024
|
||
READ_FILE_TRUNCATION_MESSAGE = (
|
||
"文件内容超过50KB,本次结果已截断。"
|
||
"请使用 start_line 和 end_line 参数指定行号范围分段读取。"
|
||
)
|
||
|
||
|
||
class ReadFileInput(BaseModel):
|
||
"""
|
||
文件读取工具的输入参数模型。
|
||
"""
|
||
|
||
file_path: str = Field(
|
||
...,
|
||
description="待读取文本文件的绝对路径",
|
||
)
|
||
start_line: Optional[int] = Field(
|
||
None,
|
||
description="起始行号,从 1 开始且包含该行;省略时从文件开头读取",
|
||
)
|
||
end_line: Optional[int] = Field(
|
||
None,
|
||
description="结束行号,从 1 开始且包含该行;省略时读取到文件末尾",
|
||
)
|
||
include_metadata: bool = Field(
|
||
False,
|
||
description="是否返回包含正文、大小、截断状态及 SHA-256 的结构化 JSON;进行受保护的完整覆盖前使用",
|
||
)
|
||
|
||
|
||
class ReadFileTool(MoviePilotTool):
|
||
"""
|
||
按行范围读取本地文本文件,并可返回文件版本元数据。
|
||
"""
|
||
|
||
name: str = Field(
|
||
default="read_file",
|
||
description="供模型调用的稳定工具名称",
|
||
)
|
||
tags: list[str] = Field(
|
||
default=[ToolTag.Read, ToolTag.File],
|
||
description="工具的能力和权限分类标签,初始化时会统一归一化",
|
||
)
|
||
description: str = Field(
|
||
default="读取文本文件内容,支持指定行范围。每次最多读取 50KB;发生截断时,缩小 start_line 和 end_line 范围后继续读取。",
|
||
description="供模型理解工具用途、输入约束和调用边界的说明",
|
||
)
|
||
args_schema: Type[BaseModel] = Field(
|
||
default=ReadFileInput,
|
||
description="工具输入参数的验证模型或 JSON Schema",
|
||
)
|
||
|
||
def get_tool_message(self, **kwargs: Any) -> Optional[str]:
|
||
"""
|
||
根据参数生成友好的提示消息
|
||
"""
|
||
file_path = kwargs.get("file_path", "")
|
||
file_name = Path(file_path).name if file_path else "未知文件"
|
||
return f"读取文件: {file_name}"
|
||
|
||
async def run( # type: ignore[override]
|
||
self,
|
||
file_path: str,
|
||
start_line: Optional[int] = None,
|
||
end_line: Optional[int] = None,
|
||
include_metadata: bool = False,
|
||
**kwargs: Any,
|
||
) -> str:
|
||
"""
|
||
读取指定文本范围,必要时附带完整文件的 SHA-256 元数据。
|
||
"""
|
||
logger.info(f"执行工具: {self.name}, 参数: file_path={file_path}, start_line={start_line}, end_line={end_line}")
|
||
|
||
try:
|
||
if start_line is not None and start_line < 1:
|
||
return "错误:start_line 必须是从 1 开始的行号;请提供有效范围后重试。"
|
||
if end_line is not None and end_line < 1:
|
||
return "错误:end_line 必须是从 1 开始的行号;请提供有效范围后重试。"
|
||
if start_line is not None and end_line is not None and end_line < start_line:
|
||
return "错误:end_line 不能小于 start_line;请调整读取范围后重试。"
|
||
resolved_path, access_error = await self._check_local_file_access(
|
||
file_path, operation="读取"
|
||
)
|
||
if access_error:
|
||
return access_error
|
||
path = AsyncPath(cast(Path, resolved_path))
|
||
|
||
if not await path.exists():
|
||
return f"错误:文件 {resolved_path} 不存在"
|
||
|
||
if not await path.is_file():
|
||
return f"错误:{resolved_path} 不是一个文件"
|
||
|
||
raw_content = await path.read_bytes()
|
||
content = raw_content.decode("utf-8", errors="replace")
|
||
truncated = False
|
||
|
||
if start_line is not None or end_line is not None:
|
||
lines = content.splitlines(keepends=True)
|
||
total_lines = len(lines)
|
||
|
||
# 将行号转换为索引(1-based -> 0-based)
|
||
s = (start_line - 1) if start_line and start_line >= 1 else 0
|
||
e = end_line if end_line and end_line >= 1 else total_lines
|
||
|
||
# 确保范围有效
|
||
s = max(0, min(s, total_lines))
|
||
e = max(s, min(e, total_lines))
|
||
|
||
content = "".join(lines[s:e])
|
||
|
||
# 检查大小限制
|
||
content_bytes = content.encode("utf-8")
|
||
if len(content_bytes) > MAX_READ_SIZE:
|
||
content = content_bytes[:MAX_READ_SIZE].decode("utf-8", errors="replace")
|
||
truncated = True
|
||
|
||
if include_metadata:
|
||
payload = {
|
||
"file_path": str(resolved_path),
|
||
"sha256": hashlib.sha256(raw_content).hexdigest(),
|
||
"size_bytes": len(raw_content),
|
||
"start_line": start_line,
|
||
"end_line": end_line,
|
||
"truncated": truncated,
|
||
}
|
||
if truncated:
|
||
payload["truncation_message"] = READ_FILE_TRUNCATION_MESSAGE
|
||
payload["content"] = content
|
||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||
|
||
if truncated:
|
||
return f"{content}\n\n[警告:{READ_FILE_TRUNCATION_MESSAGE}]"
|
||
|
||
return str(content)
|
||
|
||
except PermissionError:
|
||
return f"错误:没有权限读取 {file_path}"
|
||
except UnicodeDecodeError:
|
||
return f"错误:{file_path} 不是文本文件,无法读取"
|
||
except Exception as e:
|
||
logger.error(f"读取文件 {file_path} 时发生错误: {str(e)}", exc_info=True)
|
||
return f"操作失败: {str(e)}"
|