diff --git a/src/lsp/index.ts b/src/lsp/index.ts index e120971..2e9fe25 100644 --- a/src/lsp/index.ts +++ b/src/lsp/index.ts @@ -92,20 +92,24 @@ export function formatDiagnostics(diagnostics: FileDiagnostic[]): string { /** * 获取文件诊断并格式化为 AI 可读的格式 + * 只显示错误和警告,忽略 hint 和 info */ export async function getFormattedFileDiagnostics(filePath: string): Promise { const diagnostics = getFileDiagnostics(filePath); - if (diagnostics.length === 0) { + // 只关注错误和警告,忽略 hint 和 info + const errors = diagnostics.filter(d => d.severity === 'error'); + const warnings = diagnostics.filter(d => d.severity === 'warning'); + const relevantDiagnostics = [...errors, ...warnings]; + + // 没有错误和警告就不显示 + if (relevantDiagnostics.length === 0) { return ''; } - const errors = diagnostics.filter(d => d.severity === 'error'); - const warnings = diagnostics.filter(d => d.severity === 'warning'); - let result = `\n\n`; result += `发现 ${errors.length} 个错误, ${warnings.length} 个警告:\n`; - result += formatDiagnostics(diagnostics); + result += formatDiagnostics(relevantDiagnostics); result += '\n'; return result; diff --git a/src/permission/checkers/file.ts b/src/permission/checkers/file.ts index 15a8ab4..0ecb847 100644 --- a/src/permission/checkers/file.ts +++ b/src/permission/checkers/file.ts @@ -9,6 +9,7 @@ import type { PermissionContext, } from '../types.js'; import type { PermissionChecker } from './base.js'; +import { promptFilePermission } from '../file-prompt.js'; const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json'); @@ -251,6 +252,28 @@ export class FilePermissionChecker implements PermissionChecker { sessionKey: string, reason: string ): Promise { + // 对于 write/edit 操作,如果有内容信息,使用 diff 显示 + if ((ctx.operation === 'write' || ctx.operation === 'edit') && ctx.newContent !== undefined) { + // 更新 ctx 中的路径为绝对路径 + const ctxWithAbsPath: FilePermissionContext = { + ...ctx, + path: absolutePath, + }; + + const decision = await promptFilePermission(ctxWithAbsPath); + + if (decision.remember) { + this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny'); + } + + return { + allowed: decision.allow, + action: decision.allow ? 'allow' : 'deny', + reason: decision.allow ? '用户允许' : '用户拒绝', + }; + } + + // 其他操作使用原有的回调 if (!this.askCallback) { return { allowed: false, diff --git a/src/permission/file-prompt.ts b/src/permission/file-prompt.ts new file mode 100644 index 0000000..0afd105 --- /dev/null +++ b/src/permission/file-prompt.ts @@ -0,0 +1,186 @@ +/** + * 文件操作确认提示 + * 显示 diff 对比并让用户确认 + */ + +import * as readline from 'readline'; +import * as fs from 'fs/promises'; +import chalk from 'chalk'; +import type { FilePermissionContext, PermissionDecision } from './types.js'; +import { computeDiff, formatDiff, countChanges, formatEditDiff } from '../utils/diff.js'; + +/** + * 显示文件写入的 diff 并请求确认 + */ +export async function promptFileWrite(ctx: FilePermissionContext): Promise { + const { path: filePath, newContent } = ctx; + + if (!newContent) { + // 没有内容,使用简单确认 + return promptSimpleConfirm(ctx); + } + + // 读取原文件内容 + let oldContent: string | null = null; + try { + oldContent = await fs.readFile(filePath, 'utf-8'); + } catch { + // 文件不存在,是新文件 + } + + // 如果内容相同,直接允许 + if (oldContent === newContent) { + return { allow: true, remember: false }; + } + + // 计算 diff + const diff = computeDiff(oldContent, newContent); + const changes = countChanges(diff); + + // 显示 diff + console.log(''); + console.log(chalk.yellow('📝 文件写入预览')); + console.log(chalk.cyan('文件: ') + chalk.white(filePath)); + + if (diff.isNew) { + console.log(chalk.green('状态: ') + chalk.white('新文件')); + console.log(chalk.green(`+${changes.additions} 行`)); + } else { + console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`)); + } + + console.log(''); + console.log(chalk.gray('─'.repeat(60))); + + // 限制显示行数 + const diffOutput = formatDiff(diff, filePath); + const lines = diffOutput.split('\n'); + const MAX_LINES = 50; + + if (lines.length > MAX_LINES) { + console.log(lines.slice(0, MAX_LINES).join('\n')); + console.log(chalk.yellow(`\n... 省略 ${lines.length - MAX_LINES} 行 ...`)); + } else { + console.log(diffOutput); + } + + console.log(chalk.gray('─'.repeat(60))); + console.log(''); + + // 询问用户确认 + return promptConfirm(); +} + +/** + * 显示文件编辑的 diff 并请求确认 + */ +export async function promptFileEdit(ctx: FilePermissionContext): Promise { + const { path: filePath, oldContent, newContent } = ctx; + + if (!oldContent || !newContent) { + // 没有内容,使用简单确认 + return promptSimpleConfirm(ctx); + } + + // 显示编辑 diff + console.log(''); + console.log(chalk.yellow('✏️ 文件编辑预览')); + console.log(chalk.cyan('文件: ') + chalk.white(filePath)); + console.log(''); + console.log(chalk.gray('─'.repeat(60))); + console.log(formatEditDiff(oldContent, newContent)); + console.log(chalk.gray('─'.repeat(60))); + console.log(''); + + // 询问用户确认 + return promptConfirm(); +} + +/** + * 简单确认(无 diff) + */ +async function promptSimpleConfirm(ctx: FilePermissionContext): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + console.log(''); + console.log(chalk.yellow('⚠️ 文件操作确认')); + console.log(chalk.cyan('操作: ') + chalk.white(ctx.operation)); + console.log(chalk.cyan('文件: ') + chalk.white(ctx.path)); + console.log(''); + + showConfirmOptions(); + + rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { + rl.close(); + resolve(parseAnswer(answer)); + }); + }); +} + +/** + * 通用确认提示 + */ +async function promptConfirm(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + showConfirmOptions(); + + rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { + rl.close(); + resolve(parseAnswer(answer)); + }); + }); +} + +/** + * 显示确认选项 + */ +function showConfirmOptions(): void { + console.log(chalk.white('选择操作:')); + console.log(chalk.green(' [y] ') + '确认执行'); + console.log(chalk.green(' [Y] ') + '确认执行,并记住此类操作(本次会话)'); + console.log(chalk.red(' [n] ') + '拒绝执行'); + console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类操作(本次会话)'); + console.log(''); +} + +/** + * 解析用户输入 + */ +function parseAnswer(answer: string): PermissionDecision { + const choice = answer.trim(); + + switch (choice) { + case 'y': + return { allow: true, remember: false }; + case 'Y': + return { allow: true, remember: true }; + case 'N': + return { allow: false, remember: true }; + case 'n': + default: + return { allow: false, remember: false }; + } +} + +/** + * 根据操作类型选择合适的确认提示 + */ +export async function promptFilePermission(ctx: FilePermissionContext): Promise { + switch (ctx.operation) { + case 'write': + return promptFileWrite(ctx); + case 'edit': + return promptFileEdit(ctx); + default: + return promptSimpleConfirm(ctx); + } +} diff --git a/src/permission/index.ts b/src/permission/index.ts index f2c281a..510c913 100644 --- a/src/permission/index.ts +++ b/src/permission/index.ts @@ -16,6 +16,8 @@ export { PermissionManager, getPermissionManager, resetPermissionManager } from export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js'; +export { promptFilePermission, promptFileWrite, promptFileEdit } from './file-prompt.js'; + // Checker pattern exports export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js'; export { BashPermissionChecker } from './checkers/bash.js'; diff --git a/src/permission/types.ts b/src/permission/types.ts index f0e35e9..52704b0 100644 --- a/src/permission/types.ts +++ b/src/permission/types.ts @@ -44,6 +44,9 @@ export interface FilePermissionContext { operation: FileOperation; path: string; // 目标路径 workdir: string; // 当前工作目录 + // 用于 diff 显示的内容(仅 write/edit 操作) + newContent?: string; // 新内容 + oldContent?: string; // 原内容(edit 操作时,要被替换的部分) } // 文件权限配置 diff --git a/src/tools/filesystem/edit_file.ts b/src/tools/filesystem/edit_file.ts index b290d33..e5a89ab 100644 --- a/src/tools/filesystem/edit_file.ts +++ b/src/tools/filesystem/edit_file.ts @@ -42,32 +42,11 @@ export const editFileTool: ToolWithMetadata = { ? filePath : path.join(cwd, filePath); - // 权限检查 - const permissionManager = getPermissionManager(); - const permResult = await permissionManager.checkFilePermission({ - operation: 'edit', - path: absolutePath, - workdir: cwd, - }); - - if (!permResult.allowed) { - if (permResult.needsConfirmation) { - return { - success: false, - output: '', - error: `需要用户确认: 编辑 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`, - }; - } - return { - success: false, - output: '', - error: `权限被拒绝: ${permResult.reason || '不允许编辑此文件'}`, - }; - } - try { + // 先读取文件内容,用于验证和 diff 显示 const content = await fs.readFile(absolutePath, 'utf-8'); + // 验证 old_string 是否存在且唯一 if (!content.includes(oldString)) { return { success: false, @@ -85,6 +64,31 @@ export const editFileTool: ToolWithMetadata = { }; } + // 权限检查(传递内容用于 diff 显示) + const permissionManager = getPermissionManager(); + const permResult = await permissionManager.checkFilePermission({ + operation: 'edit', + path: absolutePath, + workdir: cwd, + oldContent: oldString, + newContent: newString, + }); + + if (!permResult.allowed) { + if (permResult.needsConfirmation) { + return { + success: false, + output: '', + error: `需要用户确认: 编辑 ${absolutePath}\n原因: ${permResult.reason || '需要权限确认'}`, + }; + } + return { + success: false, + output: '', + error: `权限被拒绝: ${permResult.reason || '不允许编辑此文件'}`, + }; + } + const newContent = content.replace(oldString, newString); await fs.writeFile(absolutePath, newContent, 'utf-8'); diff --git a/src/tools/filesystem/write_file.ts b/src/tools/filesystem/write_file.ts index a60a558..1571a2e 100644 --- a/src/tools/filesystem/write_file.ts +++ b/src/tools/filesystem/write_file.ts @@ -36,12 +36,13 @@ export const writeFileTool: ToolWithMetadata = { ? filePath : path.join(cwd, filePath); - // 权限检查 + // 权限检查(传递内容用于 diff 显示) const permissionManager = getPermissionManager(); const permResult = await permissionManager.checkFilePermission({ operation: 'write', path: absolutePath, workdir: cwd, + newContent: content, }); if (!permResult.allowed) { diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..ea33345 --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,394 @@ +/** + * 文件 diff 对比和确认工具 + * 用于在写入文件前显示变更并让用户确认 + */ + +import * as readline from 'readline'; +import * as fs from 'fs/promises'; +import chalk from 'chalk'; + +export interface DiffLine { + type: 'add' | 'remove' | 'context'; + lineNumber: number | null; + content: string; +} + +export interface DiffResult { + oldContent: string | null; + newContent: string; + isNew: boolean; + hunks: DiffHunk[]; +} + +export interface DiffHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffLine[]; +} + +/** + * 计算两个字符串的 diff + * 使用简化的 LCS (最长公共子序列) 算法 + */ +export function computeDiff(oldContent: string | null, newContent: string): DiffResult { + const isNew = oldContent === null; + const oldLines = oldContent ? oldContent.split('\n') : []; + const newLines = newContent.split('\n'); + + if (isNew) { + // 新文件,所有行都是新增 + return { + oldContent, + newContent, + isNew: true, + hunks: [{ + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: newLines.length, + lines: newLines.map((line, i) => ({ + type: 'add' as const, + lineNumber: i + 1, + content: line, + })), + }], + }; + } + + // 计算 diff hunks + const hunks = computeHunks(oldLines, newLines); + + return { + oldContent, + newContent, + isNew: false, + hunks, + }; +} + +/** + * 计算 diff hunks + */ +function computeHunks(oldLines: string[], newLines: string[]): DiffHunk[] { + // 使用简化的 diff 算法 + const lcs = computeLCS(oldLines, newLines); + const hunks: DiffHunk[] = []; + + let oldIdx = 0; + let newIdx = 0; + let lcsIdx = 0; + let currentHunk: DiffHunk | null = null; + + const CONTEXT_LINES = 3; + + while (oldIdx < oldLines.length || newIdx < newLines.length) { + const lcsLine = lcsIdx < lcs.length ? lcs[lcsIdx] : null; + + // 检查是否匹配 LCS + const oldMatch = lcsLine !== null && oldIdx < oldLines.length && oldLines[oldIdx] === lcsLine.content; + const newMatch = lcsLine !== null && newIdx < newLines.length && newLines[newIdx] === lcsLine.content; + + if (oldMatch && newMatch) { + // 上下文行 + if (currentHunk) { + currentHunk.lines.push({ + type: 'context', + lineNumber: newIdx + 1, + content: newLines[newIdx], + }); + currentHunk.oldCount++; + currentHunk.newCount++; + } + oldIdx++; + newIdx++; + lcsIdx++; + } else if (!oldMatch && oldIdx < oldLines.length && (lcsLine === null || oldLines[oldIdx] !== lcsLine.content)) { + // 删除行 + if (!currentHunk) { + currentHunk = { + oldStart: oldIdx + 1, + oldCount: 0, + newStart: newIdx + 1, + newCount: 0, + lines: [], + }; + } + currentHunk.lines.push({ + type: 'remove', + lineNumber: oldIdx + 1, + content: oldLines[oldIdx], + }); + currentHunk.oldCount++; + oldIdx++; + } else if (!newMatch && newIdx < newLines.length) { + // 新增行 + if (!currentHunk) { + currentHunk = { + oldStart: oldIdx + 1, + oldCount: 0, + newStart: newIdx + 1, + newCount: 0, + lines: [], + }; + } + currentHunk.lines.push({ + type: 'add', + lineNumber: newIdx + 1, + content: newLines[newIdx], + }); + currentHunk.newCount++; + newIdx++; + } else { + // 匹配但还没到 + if (currentHunk && currentHunk.lines.length > 0) { + // 检查是否应该结束当前 hunk + const lastNonContext = [...currentHunk.lines].reverse().findIndex(l => l.type !== 'context'); + if (lastNonContext > CONTEXT_LINES) { + hunks.push(currentHunk); + currentHunk = null; + } + } + oldIdx++; + newIdx++; + if (lcsLine) lcsIdx++; + } + } + + if (currentHunk && currentHunk.lines.some(l => l.type !== 'context')) { + hunks.push(currentHunk); + } + + // 如果没有实际变化,返回空 + if (hunks.length === 0 && oldLines.join('\n') !== newLines.join('\n')) { + // 全文替换的情况 + return [{ + oldStart: 1, + oldCount: oldLines.length, + newStart: 1, + newCount: newLines.length, + lines: [ + ...oldLines.map((line, i) => ({ + type: 'remove' as const, + lineNumber: i + 1, + content: line, + })), + ...newLines.map((line, i) => ({ + type: 'add' as const, + lineNumber: i + 1, + content: line, + })), + ], + }]; + } + + return hunks; +} + +/** + * 计算最长公共子序列 + */ +function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: string; oldIdx: number; newIdx: number }> { + const m = oldLines.length; + const n = newLines.length; + + // DP 表 + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // 回溯找出 LCS + const result: Array<{ content: string; oldIdx: number; newIdx: number }> = []; + let i = m, j = n; + + while (i > 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + result.unshift({ content: oldLines[i - 1], oldIdx: i - 1, newIdx: j - 1 }); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return result; +} + +/** + * 格式化 diff 输出 + */ +export function formatDiff(diff: DiffResult, filePath: string): string { + const lines: string[] = []; + + if (diff.isNew) { + lines.push(chalk.green(`+++ 新文件: ${filePath}`)); + } else { + lines.push(chalk.gray(`--- ${filePath} (原文件)`)); + lines.push(chalk.green(`+++ ${filePath} (修改后)`)); + } + + lines.push(''); + + for (const hunk of diff.hunks) { + // Hunk 头部 + lines.push(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`)); + + for (const line of hunk.lines) { + const lineNum = line.lineNumber ? chalk.gray(`${line.lineNumber.toString().padStart(4)} `) : ' '; + + switch (line.type) { + case 'add': + lines.push(chalk.green(`${lineNum}+ ${line.content}`)); + break; + case 'remove': + lines.push(chalk.red(`${lineNum}- ${line.content}`)); + break; + case 'context': + lines.push(chalk.gray(`${lineNum} ${line.content}`)); + break; + } + } + + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * 统计变更数量 + */ +export function countChanges(diff: DiffResult): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + + for (const hunk of diff.hunks) { + for (const line of hunk.lines) { + if (line.type === 'add') additions++; + if (line.type === 'remove') deletions++; + } + } + + return { additions, deletions }; +} + +export interface FileConfirmResult { + confirmed: boolean; + remember: boolean; +} + +/** + * 显示 diff 并让用户确认 + */ +export async function confirmFileChange( + filePath: string, + newContent: string, + operation: 'write' | 'edit' +): Promise { + // 读取原文件内容 + let oldContent: string | null = null; + try { + oldContent = await fs.readFile(filePath, 'utf-8'); + } catch { + // 文件不存在,是新文件 + } + + // 如果内容相同,直接通过 + if (oldContent === newContent) { + return { confirmed: true, remember: false }; + } + + // 计算 diff + const diff = computeDiff(oldContent, newContent); + const changes = countChanges(diff); + + // 显示 diff + console.log(''); + console.log(chalk.yellow('📝 文件变更预览')); + console.log(chalk.cyan('操作: ') + chalk.white(operation === 'write' ? '写入文件' : '编辑文件')); + console.log(chalk.cyan('文件: ') + chalk.white(filePath)); + console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`)); + console.log(''); + console.log(chalk.gray('─'.repeat(60))); + console.log(formatDiff(diff, filePath)); + console.log(chalk.gray('─'.repeat(60))); + console.log(''); + + // 询问用户确认 + return promptFileConfirm(); +} + +/** + * 提示用户确认文件操作 + */ +async function promptFileConfirm(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + console.log(chalk.white('选择操作:')); + console.log(chalk.green(' [y] ') + '确认写入'); + console.log(chalk.green(' [Y] ') + '确认写入,并记住此类操作(本次会话)'); + console.log(chalk.red(' [n] ') + '取消操作'); + console.log(chalk.red(' [N] ') + '取消操作,并记住此类操作(本次会话)'); + console.log(''); + + rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { + rl.close(); + + const choice = answer.trim(); + + switch (choice) { + case 'y': + resolve({ confirmed: true, remember: false }); + break; + case 'Y': + resolve({ confirmed: true, remember: true }); + break; + case 'N': + resolve({ confirmed: false, remember: true }); + break; + case 'n': + default: + resolve({ confirmed: false, remember: false }); + break; + } + }); + }); +} + +/** + * 简化的 diff 显示(用于编辑操作,只显示变更部分) + */ +export function formatEditDiff(oldString: string, newString: string): string { + const lines: string[] = []; + + lines.push(chalk.gray('变更内容:')); + + // 显示删除的内容 + const oldLines = oldString.split('\n'); + for (const line of oldLines) { + lines.push(chalk.red(`- ${line}`)); + } + + // 显示新增的内容 + const newLines = newString.split('\n'); + for (const line of newLines) { + lines.push(chalk.green(`+ ${line}`)); + } + + return lines.join('\n'); +}