feat: read_file 全面增强 — binary模式/字节分页/5MB限制/截断提示hint/offset_bytes+limit_bytes
This commit is contained in:
@@ -8,20 +8,98 @@ import { spawn } from 'child_process';
|
||||
import { checkPathAllowed } from './tool-security.js';
|
||||
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
||||
|
||||
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number }): Promise<ToolResult> {
|
||||
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise<ToolResult> {
|
||||
try {
|
||||
const filePath = resolvePath(params.path);
|
||||
const allowed = checkPathAllowed(filePath, 'read');
|
||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > 1024 * 1024) {
|
||||
return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 1MB` };
|
||||
const MAX_SIZE = params.mode === 'binary' ? 50 * 1024 * 1024 : 5 * 1024 * 1024; // 二进制50MB,文本5MB
|
||||
if (stat.size > MAX_SIZE) {
|
||||
return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 ${MAX_SIZE / 1024 / 1024}MB` };
|
||||
}
|
||||
|
||||
const mode = params.mode || 'text';
|
||||
|
||||
// ── 二进制模式:返回 base64 ──
|
||||
if (mode === 'binary') {
|
||||
const buf = await fs.readFile(filePath);
|
||||
let resultBuf = buf;
|
||||
let truncated = false;
|
||||
let byteRange: [number, number] = [0, buf.length];
|
||||
|
||||
if (params.offset_bytes !== undefined || params.limit_bytes !== undefined) {
|
||||
const offset = params.offset_bytes || 0;
|
||||
const limit = params.limit_bytes || Math.min(100 * 1024, buf.length - offset); // 默认100KB
|
||||
const end = Math.min(buf.length, offset + limit);
|
||||
resultBuf = buf.subarray(offset, end);
|
||||
byteRange = [offset, end];
|
||||
truncated = end < buf.length;
|
||||
} else if (buf.length > 100 * 1024) {
|
||||
resultBuf = buf.subarray(0, 100 * 1024);
|
||||
byteRange = [0, 100 * 1024];
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
content: resultBuf.toString('base64'),
|
||||
encoding: 'base64',
|
||||
mode: 'binary',
|
||||
size: stat.size,
|
||||
bytes_read: resultBuf.length,
|
||||
byte_range: byteRange,
|
||||
truncated,
|
||||
...(truncated && {
|
||||
remaining_bytes: stat.size - byteRange[1],
|
||||
hint: `文件共 ${stat.size} 字节(${(stat.size / 1024 / 1024).toFixed(1)}MB),已返回 ${byteRange[1]} 字节。使用 offset_bytes=${byteRange[1]} 继续读取后续内容。`
|
||||
}),
|
||||
mime_hint: extToMime(ext),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 文本模式 ──
|
||||
const encoding = (params.encoding as BufferEncoding) || 'utf-8';
|
||||
|
||||
// 字节分页优先
|
||||
if (params.offset_bytes !== undefined || params.limit_bytes !== undefined) {
|
||||
const offset = params.offset_bytes || 0;
|
||||
const limit = params.limit_bytes || 50000;
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(limit, stat.size - offset));
|
||||
await fd.read(buf, 0, buf.length, offset);
|
||||
const content = buf.toString(encoding);
|
||||
const contentLines = content.split('\n');
|
||||
const end = offset + buf.length;
|
||||
const truncated = end < stat.size;
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
content,
|
||||
encoding,
|
||||
mode: 'text',
|
||||
size: stat.size,
|
||||
lines: contentLines.length,
|
||||
byte_range: [offset, end],
|
||||
truncated,
|
||||
...(truncated && {
|
||||
remaining_bytes: stat.size - end,
|
||||
hint: `文件共 ${stat.size} 字节,已返回 ${offset}-${end} 的内容。使用 offset_bytes=${end} 继续读取。`
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 按行读取
|
||||
const content = await fs.readFile(filePath, encoding);
|
||||
const lines = content.split('\n');
|
||||
const defaultLimit = 500;
|
||||
|
||||
let resultContent = content;
|
||||
let lineRange: [number, number] = [1, lines.length];
|
||||
@@ -29,13 +107,13 @@ export async function handleReadFile(params: { path: string; encoding?: string;
|
||||
|
||||
if (params.start_line || params.end_line) {
|
||||
const start = Math.max(1, params.start_line || 1) - 1;
|
||||
const end = Math.min(lines.length, params.end_line || Math.min(start + 500, lines.length));
|
||||
const end = Math.min(lines.length, params.end_line || Math.min(start + defaultLimit, lines.length));
|
||||
resultContent = lines.slice(start, end).join('\n');
|
||||
lineRange = [start + 1, end];
|
||||
truncated = end < lines.length;
|
||||
} else if (lines.length > 500) {
|
||||
resultContent = lines.slice(0, 500).join('\n');
|
||||
lineRange = [1, 500];
|
||||
} else if (lines.length > defaultLimit) {
|
||||
resultContent = lines.slice(0, defaultLimit).join('\n');
|
||||
lineRange = [1, defaultLimit];
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
@@ -44,16 +122,38 @@ export async function handleReadFile(params: { path: string; encoding?: string;
|
||||
path: filePath,
|
||||
content: resultContent,
|
||||
encoding,
|
||||
mode: 'text',
|
||||
size: stat.size,
|
||||
lines: lines.length,
|
||||
line_range: lineRange,
|
||||
truncated,
|
||||
line_range: lineRange
|
||||
...(truncated && {
|
||||
remaining_lines: lines.length - lineRange[1],
|
||||
hint: `文件共 ${lines.length} 行,已返回第 ${lineRange[0]}-${lineRange[1]} 行。使用 start_line=${lineRange[1] + 1} 继续读取后续内容。`
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** 扩展名 → MIME 类型 */
|
||||
function extToMime(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||||
'.pdf': 'application/pdf', '.zip': 'application/zip',
|
||||
'.gz': 'application/gzip', '.tar': 'application/x-tar',
|
||||
'.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||
'.json': 'application/json', '.xml': 'application/xml',
|
||||
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||
'.ts': 'text/typescript', '.py': 'text/x-python',
|
||||
'.wasm': 'application/wasm', '.bin': 'application/octet-stream',
|
||||
'.woff2': 'font/woff2', '.ttf': 'font/ttf',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function handleWriteFile(params: { path: string; content: string; encoding?: string }): Promise<ToolResult> {
|
||||
try {
|
||||
const filePath = resolvePath(params.path);
|
||||
|
||||
@@ -13,15 +13,18 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_file',
|
||||
description: 'Read the contents of a local file. Returns the file content as a string. Supports text files up to 1MB. Use start_line/end_line for large files.',
|
||||
description: 'Read a file from the local filesystem. Supports text mode (utf-8/latin1, line-based pagination) and binary mode (base64, byte-based pagination). Files up to 5MB (text) or 50MB (binary). When truncated, returns remaining_lines/remaining_bytes and a hint to continue reading. Use start_line/end_line for text files, offset_bytes/limit_bytes for binary files or raw byte access.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
required: ['path'],
|
||||
properties: {
|
||||
path: { type: 'string', description: 'The file path to read. Absolute or relative.' },
|
||||
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8' },
|
||||
start_line: { type: 'integer', description: 'Start line (1-indexed). For reading specific sections.' },
|
||||
end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 500.' }
|
||||
path: { type: 'string', description: 'The file path to read. Absolute or relative to workspace.' },
|
||||
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Default: utf-8. Use base64 for binary content.' },
|
||||
start_line: { type: 'integer', description: 'Start line (1-indexed) for text files. Use with end_line for pagination.' },
|
||||
end_line: { type: 'integer', description: 'End line (inclusive). Default: start_line + 500.' },
|
||||
mode: { type: 'string', enum: ['text', 'binary'], description: 'Read mode. text = decode as string, binary = return base64. Default: text.' },
|
||||
offset_bytes: { type: 'integer', description: 'Byte offset to start reading from (0-indexed). Use for binary files or large text.' },
|
||||
limit_bytes: { type: 'integer', description: 'Max bytes to read. Default: 50000 (text) or 102400 (binary).' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user