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 { checkPathAllowed } from './tool-security.js';
|
||||||
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.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 {
|
try {
|
||||||
const filePath = resolvePath(params.path);
|
const filePath = resolvePath(params.path);
|
||||||
const allowed = checkPathAllowed(filePath, 'read');
|
const allowed = checkPathAllowed(filePath, 'read');
|
||||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||||
|
|
||||||
const stat = await fs.stat(filePath);
|
const stat = await fs.stat(filePath);
|
||||||
if (stat.size > 1024 * 1024) {
|
const MAX_SIZE = params.mode === 'binary' ? 50 * 1024 * 1024 : 5 * 1024 * 1024; // 二进制50MB,文本5MB
|
||||||
return { success: false, error: `文件过大 (${(stat.size / 1024 / 1024).toFixed(1)}MB),最大支持 1MB` };
|
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';
|
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 content = await fs.readFile(filePath, encoding);
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
const defaultLimit = 500;
|
||||||
|
|
||||||
let resultContent = content;
|
let resultContent = content;
|
||||||
let lineRange: [number, number] = [1, lines.length];
|
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) {
|
if (params.start_line || params.end_line) {
|
||||||
const start = Math.max(1, params.start_line || 1) - 1;
|
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');
|
resultContent = lines.slice(start, end).join('\n');
|
||||||
lineRange = [start + 1, end];
|
lineRange = [start + 1, end];
|
||||||
truncated = end < lines.length;
|
truncated = end < lines.length;
|
||||||
} else if (lines.length > 500) {
|
} else if (lines.length > defaultLimit) {
|
||||||
resultContent = lines.slice(0, 500).join('\n');
|
resultContent = lines.slice(0, defaultLimit).join('\n');
|
||||||
lineRange = [1, 500];
|
lineRange = [1, defaultLimit];
|
||||||
truncated = true;
|
truncated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,16 +122,38 @@ export async function handleReadFile(params: { path: string; encoding?: string;
|
|||||||
path: filePath,
|
path: filePath,
|
||||||
content: resultContent,
|
content: resultContent,
|
||||||
encoding,
|
encoding,
|
||||||
|
mode: 'text',
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
lines: lines.length,
|
lines: lines.length,
|
||||||
|
line_range: lineRange,
|
||||||
truncated,
|
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) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
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> {
|
export async function handleWriteFile(params: { path: string; content: string; encoding?: string }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const filePath = resolvePath(params.path);
|
const filePath = resolvePath(params.path);
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'read_file',
|
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: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['path'],
|
required: ['path'],
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string', description: 'The file path to read. Absolute or relative.' },
|
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' },
|
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 reading specific sections.' },
|
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.' }
|
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