From 751f548b0c731fd376c46485e02d07fca355088f Mon Sep 17 00:00:00 2001 From: thzxx Date: Wed, 10 Jun 2026 14:11:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20read=5Ffile=20=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20=E2=80=94=20binary=E6=A8=A1=E5=BC=8F/?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=88=86=E9=A1=B5/5MB=E9=99=90=E5=88=B6/?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E6=8F=90=E7=A4=BAhint/offset=5Fbytes+limit?= =?UTF-8?q?=5Fbytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/tool-handlers-fs.ts | 116 +++++++++++++++++++++++-- src/renderer/services/tool-registry.ts | 13 +-- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts index 7e5f5a0..a41a3b1 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -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 { +export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise { 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 = { + '.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 { try { const filePath = resolvePath(params.path); diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts index 9f48c4a..13edd2f 100644 --- a/src/renderer/services/tool-registry.ts +++ b/src/renderer/services/tool-registry.ts @@ -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).' } } } }