feat(permission): 实现 WebSocket 权限确认机制
重构权限系统,将终端 UI 代码从 core 模块移除,实现基于 WebSocket 的权限确认流程: Core 模块清理: - 删除 permission/prompt.ts 和 file-prompt.ts(终端交互) - 删除 diff.ts 中的 chalk 渲染函数 - 删除 config.ts 中的 inquirer 交互 - 移除 chalk 依赖 Server 权限处理: - 新增 permission/handler.ts,实现 WebSocket 权限请求/响应 - 更新 agent/adapter.ts 设置权限回调 - 更新 ws.ts 处理 permission_response 消息 Web 权限组件: - 新增 PermissionDialog 组件,显示权限请求详情和 Diff - 更新 useChat hook 管理权限状态 - 更新 Chat 页面集成权限弹窗
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export { Agent } from './core/agent.js';
|
||||
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
|
||||
export { loadConfig, initConfig } from './utils/config.js';
|
||||
export { loadConfig, saveConfig, getConfig, loadVisionConfig } from './utils/config.js';
|
||||
export type { VisionConfig } from './utils/config.js';
|
||||
export { SessionStorage } from './session/storage.js';
|
||||
export { SessionManager } from './session/index.js';
|
||||
export type { SessionData, SessionSummary } from './session/types.js';
|
||||
@@ -9,7 +10,15 @@ export type { SessionData, SessionSummary } from './session/types.js';
|
||||
export type { UserInput } from './types/index.js';
|
||||
|
||||
// Permission
|
||||
export { getPermissionManager, promptPermission } from './permission/index.js';
|
||||
export { getPermissionManager } from './permission/index.js';
|
||||
export type {
|
||||
PermissionContext,
|
||||
PermissionDecision,
|
||||
PermissionCheckResult,
|
||||
FilePermissionContext,
|
||||
GitPermissionContext,
|
||||
WebPermissionContext,
|
||||
} from './permission/index.js';
|
||||
|
||||
// LSP
|
||||
export { initLSP, shutdownLSP } from './lsp/index.js';
|
||||
|
||||
@@ -9,7 +9,6 @@ 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');
|
||||
@@ -252,28 +251,7 @@ export class FilePermissionChecker implements PermissionChecker {
|
||||
sessionKey: string,
|
||||
reason: string
|
||||
): Promise<PermissionCheckResult> {
|
||||
// 对于 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,
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 文件操作确认提示
|
||||
* 显示 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<PermissionDecision> {
|
||||
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<PermissionDecision> {
|
||||
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<PermissionDecision> {
|
||||
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<PermissionDecision> {
|
||||
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<PermissionDecision> {
|
||||
switch (ctx.operation) {
|
||||
case 'write':
|
||||
return promptFileWrite(ctx);
|
||||
case 'edit':
|
||||
return promptFileEdit(ctx);
|
||||
default:
|
||||
return promptSimpleConfirm(ctx);
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,17 @@ export type {
|
||||
FileOperation,
|
||||
FilePermissionContext,
|
||||
FilePermissionConfig,
|
||||
WebPermissionContext,
|
||||
WebPermissionConfig,
|
||||
GitOperation,
|
||||
GitPermissionContext,
|
||||
GitPermissionConfig,
|
||||
} from './types.js';
|
||||
|
||||
export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js';
|
||||
|
||||
export { PermissionManager, getPermissionManager, resetPermissionManager } from './manager.js';
|
||||
|
||||
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';
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import * as readline from 'readline';
|
||||
import chalk from 'chalk';
|
||||
import type { PermissionContext, PermissionDecision } from './types.js';
|
||||
|
||||
/**
|
||||
* 在终端中提示用户确认权限
|
||||
*/
|
||||
export async function promptPermission(ctx: PermissionContext): Promise<PermissionDecision> {
|
||||
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.command));
|
||||
console.log(chalk.cyan('目录: ') + chalk.gray(ctx.workdir));
|
||||
|
||||
if (ctx.externalPaths && ctx.externalPaths.length > 0) {
|
||||
console.log(chalk.red('⚠️ 此命令访问项目目录外的路径:'));
|
||||
ctx.externalPaths.forEach(p => {
|
||||
console.log(chalk.red(' • ') + chalk.gray(p));
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.patterns && ctx.patterns.length > 0) {
|
||||
console.log(chalk.gray('匹配模式: ') + ctx.patterns.join(', '));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
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({ allow: true, remember: false });
|
||||
break;
|
||||
case 'Y':
|
||||
resolve({ allow: true, remember: true });
|
||||
break;
|
||||
case 'N':
|
||||
resolve({ allow: false, remember: true });
|
||||
break;
|
||||
case 'n':
|
||||
default:
|
||||
resolve({ allow: false, remember: false });
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示权限被拒绝的消息
|
||||
*/
|
||||
export function showPermissionDenied(command: string, reason: string): void {
|
||||
console.log('');
|
||||
console.log(chalk.red('🚫 权限被拒绝'));
|
||||
console.log(chalk.cyan('命令: ') + chalk.white(command));
|
||||
console.log(chalk.cyan('原因: ') + chalk.gray(reason));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示权限允许的消息
|
||||
*/
|
||||
export function showPermissionAllowed(command: string): void {
|
||||
console.log(chalk.green('✓ ') + chalk.gray(`执行: ${command}`));
|
||||
}
|
||||
@@ -223,164 +223,3 @@ export function saveConfig(config: Partial<StoredConfig>): void {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2));
|
||||
}
|
||||
|
||||
// 初始化配置向导
|
||||
export async function initConfig(): Promise<void> {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
|
||||
console.log('\n🔧 初始化 AI Terminal Assistant 配置\n');
|
||||
|
||||
// 选择 provider
|
||||
const { provider } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'provider',
|
||||
message: '选择 AI 服务商:',
|
||||
choices: [
|
||||
{ name: 'Anthropic (Claude)', value: 'anthropic' },
|
||||
{ name: 'OpenAI (GPT)', value: 'openai' },
|
||||
{ name: 'OpenAI 兼容服务 (阿里云百炼、Azure 等)', value: 'openai-compatible' },
|
||||
{ name: 'DeepSeek', value: 'deepseek' },
|
||||
],
|
||||
default: 'anthropic',
|
||||
},
|
||||
]);
|
||||
|
||||
// 是否是 OpenAI 兼容服务
|
||||
const isOpenAICompatible = provider === 'openai-compatible';
|
||||
const actualProvider = isOpenAICompatible ? 'openai' : provider;
|
||||
|
||||
// 如果是 OpenAI 兼容服务,询问 base URL
|
||||
let baseUrl: string | undefined;
|
||||
if (isOpenAICompatible) {
|
||||
const { customBaseUrl } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customBaseUrl',
|
||||
message: '请输入 API 基础 URL (如: https://dashscope.aliyuncs.com/compatible-mode/v1):',
|
||||
validate: (input: string) => {
|
||||
if (!input) return 'Base URL 不能为空';
|
||||
try {
|
||||
new URL(input);
|
||||
return true;
|
||||
} catch {
|
||||
return '请输入有效的 URL';
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
baseUrl = customBaseUrl;
|
||||
}
|
||||
|
||||
// 根据 provider 显示不同的模型选项
|
||||
let modelChoices: Array<{ name: string; value: string }>;
|
||||
let allowCustomModel = false;
|
||||
|
||||
if (actualProvider === 'anthropic') {
|
||||
modelChoices = [
|
||||
{ name: 'Claude Sonnet 4 (推荐,平衡性能和成本)', value: 'claude-sonnet-4-20250514' },
|
||||
{ name: 'Claude Opus 4 (最强,成本较高)', value: 'claude-opus-4-20250514' },
|
||||
{ name: 'Claude 3.5 Haiku (快速,成本低)', value: 'claude-3-5-haiku-20241022' },
|
||||
];
|
||||
} else if (actualProvider === 'openai') {
|
||||
if (isOpenAICompatible) {
|
||||
// OpenAI 兼容服务允许自定义模型名称
|
||||
modelChoices = [
|
||||
{ name: 'qwen-plus (通义千问)', value: 'qwen-plus' },
|
||||
{ name: 'qwen-turbo (通义千问快速版)', value: 'qwen-turbo' },
|
||||
{ name: 'qwen-max (通义千问最强版)', value: 'qwen-max' },
|
||||
{ name: 'gpt-4o', value: 'gpt-4o' },
|
||||
{ name: '自定义模型名称...', value: '__custom__' },
|
||||
];
|
||||
allowCustomModel = true;
|
||||
} else {
|
||||
modelChoices = [
|
||||
{ name: 'GPT-4o (推荐,支持 vision)', value: 'gpt-4o' },
|
||||
{ name: 'GPT-4o mini (快速,成本低)', value: 'gpt-4o-mini' },
|
||||
{ name: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
|
||||
{ name: 'o1 (推理增强)', value: 'o1' },
|
||||
{ name: 'o1-mini (推理,成本低)', value: 'o1-mini' },
|
||||
];
|
||||
}
|
||||
} else {
|
||||
modelChoices = [
|
||||
{ name: 'DeepSeek Chat (推荐)', value: 'deepseek-chat' },
|
||||
{ name: 'DeepSeek Reasoner (推理增强)', value: 'deepseek-reasoner' },
|
||||
];
|
||||
}
|
||||
|
||||
const apiKeyMessageMap: Record<string, string> = {
|
||||
anthropic: '请输入你的 Anthropic API Key:',
|
||||
openai: '请输入你的 OpenAI API Key:',
|
||||
deepseek: '请输入你的 DeepSeek API Key:',
|
||||
};
|
||||
|
||||
// 分开询问 API Key
|
||||
const { apiKey } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: isOpenAICompatible ? '请输入你的 API Key:' : apiKeyMessageMap[actualProvider],
|
||||
validate: (input: string) => input.length > 0 || 'API Key 不能为空',
|
||||
},
|
||||
]);
|
||||
|
||||
// 询问模型配置
|
||||
const { model: selectedModel } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'model',
|
||||
message: '选择默认模型:',
|
||||
choices: modelChoices,
|
||||
default: DEFAULT_MODELS[actualProvider as ProviderType],
|
||||
},
|
||||
]);
|
||||
|
||||
// 如果选择自定义模型,询问模型名称
|
||||
let finalModel = selectedModel;
|
||||
if (allowCustomModel && selectedModel === '__custom__') {
|
||||
const { customModel } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customModel',
|
||||
message: '请输入模型名称:',
|
||||
validate: (input: string) => input.length > 0 || '模型名称不能为空',
|
||||
},
|
||||
]);
|
||||
finalModel = customModel;
|
||||
}
|
||||
|
||||
// 询问 token 配置
|
||||
const { maxTokens } = await inquirer.prompt([
|
||||
{
|
||||
type: 'number',
|
||||
name: 'maxTokens',
|
||||
message: '最大输出 token 数:',
|
||||
default: 4096,
|
||||
},
|
||||
]);
|
||||
|
||||
// 根据 provider 构建配置对象
|
||||
const configToSave: Partial<StoredConfig> = {
|
||||
provider: actualProvider as ProviderType,
|
||||
model: finalModel,
|
||||
maxTokens,
|
||||
};
|
||||
|
||||
// 存储 API Key 到对应字段
|
||||
if (actualProvider === 'anthropic') {
|
||||
configToSave.apiKey = apiKey;
|
||||
} else if (actualProvider === 'openai') {
|
||||
configToSave.openaiApiKey = apiKey;
|
||||
} else if (actualProvider === 'deepseek') {
|
||||
configToSave.deepseekApiKey = apiKey;
|
||||
}
|
||||
|
||||
// 存储 base URL
|
||||
if (baseUrl) {
|
||||
configToSave.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
saveConfig(configToSave);
|
||||
console.log('\n✅ 配置已保存到', CONFIG_FILE);
|
||||
console.log('现在可以运行 ai-assist 开始使用了!\n');
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/**
|
||||
* 文件 diff 对比和确认工具
|
||||
* 用于在写入文件前显示变更并让用户确认
|
||||
* 文件 diff 计算工具
|
||||
* 纯计算逻辑,不包含任何 UI 渲染
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -225,47 +221,6 @@ function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: st
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计变更数量
|
||||
*/
|
||||
@@ -282,113 +237,3 @@ export function countChanges(diff: DiffResult): { additions: number; deletions:
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
export interface FileConfirmResult {
|
||||
confirmed: boolean;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 diff 并让用户确认
|
||||
*/
|
||||
export async function confirmFileChange(
|
||||
filePath: string,
|
||||
newContent: string,
|
||||
operation: 'write' | 'edit'
|
||||
): Promise<FileConfirmResult> {
|
||||
// 读取原文件内容
|
||||
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<FileConfirmResult> {
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user