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:
2025-12-13 01:09:35 +08:00
parent 5d4afecd48
commit 1d69fd876d
20 changed files with 739 additions and 1560 deletions
-161
View File
@@ -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');
}
+2 -157
View File
@@ -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');
}