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:
@@ -52,7 +52,6 @@
|
|||||||
"@ai-sdk/openai": "^2.0.80",
|
"@ai-sdk/openai": "^2.0.80",
|
||||||
"@tavily/core": "^0.6.0",
|
"@tavily/core": "^0.6.0",
|
||||||
"ai": "^5.0.108",
|
"ai": "^5.0.108",
|
||||||
"chalk": "^5.3.0",
|
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { Agent } from './core/agent.js';
|
export { Agent } from './core/agent.js';
|
||||||
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.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 { SessionStorage } from './session/storage.js';
|
||||||
export { SessionManager } from './session/index.js';
|
export { SessionManager } from './session/index.js';
|
||||||
export type { SessionData, SessionSummary } from './session/types.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';
|
export type { UserInput } from './types/index.js';
|
||||||
|
|
||||||
// Permission
|
// 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
|
// LSP
|
||||||
export { initLSP, shutdownLSP } from './lsp/index.js';
|
export { initLSP, shutdownLSP } from './lsp/index.js';
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
PermissionContext,
|
PermissionContext,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type { PermissionChecker } from './base.js';
|
import type { PermissionChecker } from './base.js';
|
||||||
import { promptFilePermission } from '../file-prompt.js';
|
|
||||||
|
|
||||||
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant');
|
||||||
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
|
const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json');
|
||||||
@@ -252,28 +251,7 @@ export class FilePermissionChecker implements PermissionChecker {
|
|||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
reason: string
|
reason: string
|
||||||
): Promise<PermissionCheckResult> {
|
): 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) {
|
if (!this.askCallback) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
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,
|
FileOperation,
|
||||||
FilePermissionContext,
|
FilePermissionContext,
|
||||||
FilePermissionConfig,
|
FilePermissionConfig,
|
||||||
|
WebPermissionContext,
|
||||||
|
WebPermissionConfig,
|
||||||
|
GitOperation,
|
||||||
|
GitPermissionContext,
|
||||||
|
GitPermissionConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js';
|
export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js';
|
||||||
|
|
||||||
export { PermissionManager, getPermissionManager, resetPermissionManager } from './manager.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
|
// Checker pattern exports
|
||||||
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
|
export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js';
|
||||||
export { BashPermissionChecker } from './checkers/bash.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));
|
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 {
|
export interface DiffLine {
|
||||||
type: 'add' | 'remove' | 'context';
|
type: 'add' | 'remove' | 'context';
|
||||||
lineNumber: number | null;
|
lineNumber: number | null;
|
||||||
@@ -225,47 +221,6 @@ function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: st
|
|||||||
return result;
|
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 };
|
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');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
promptFileWrite,
|
|
||||||
promptFileEdit,
|
|
||||||
promptFilePermission,
|
|
||||||
} from '../../../src/permission/file-prompt.js';
|
|
||||||
import type { FilePermissionContext } from '../../../src/permission/types.js';
|
|
||||||
|
|
||||||
// Mock readline
|
|
||||||
vi.mock('readline', () => ({
|
|
||||||
createInterface: vi.fn(() => ({
|
|
||||||
question: vi.fn(),
|
|
||||||
close: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fs/promises
|
|
||||||
vi.mock('fs/promises', () => ({
|
|
||||||
readFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock chalk
|
|
||||||
vi.mock('chalk', () => ({
|
|
||||||
default: {
|
|
||||||
yellow: (s: string) => s,
|
|
||||||
cyan: (s: string) => s,
|
|
||||||
white: (s: string) => s,
|
|
||||||
gray: (s: string) => s,
|
|
||||||
red: (s: string) => s,
|
|
||||||
green: (s: string) => s,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock diff utils
|
|
||||||
vi.mock('../../../src/utils/diff.js', () => ({
|
|
||||||
computeDiff: vi.fn(() => ({
|
|
||||||
isNew: false,
|
|
||||||
oldContent: 'old',
|
|
||||||
newContent: 'new',
|
|
||||||
hunks: [],
|
|
||||||
})),
|
|
||||||
formatDiff: vi.fn(() => 'diff output'),
|
|
||||||
countChanges: vi.fn(() => ({ additions: 5, deletions: 3 })),
|
|
||||||
formatEditDiff: vi.fn(() => 'edit diff output'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as readline from 'readline';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
|
||||||
|
|
||||||
describe('File Prompt - 文件操作提示', () => {
|
|
||||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promptFileWrite - 文件写入提示', () => {
|
|
||||||
const baseContext: FilePermissionContext = {
|
|
||||||
operation: 'write',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'write_file',
|
|
||||||
newContent: 'new content',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('无内容时使用简单确认', async () => {
|
|
||||||
const ctx: FilePermissionContext = {
|
|
||||||
...baseContext,
|
|
||||||
newContent: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(ctx);
|
|
||||||
|
|
||||||
expect(result.allow).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('内容相同时直接允许', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('new content');
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('新文件显示新增行数', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
vi.mocked(computeDiff).mockReturnValue({
|
|
||||||
isNew: true,
|
|
||||||
oldContent: null,
|
|
||||||
newContent: 'new content',
|
|
||||||
hunks: [],
|
|
||||||
} as any);
|
|
||||||
vi.mocked(countChanges).mockReturnValue({ additions: 10, deletions: 0 });
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('新文件');
|
|
||||||
expect(calls).toContain('+10 行');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('修改文件显示增删行数', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
vi.mocked(computeDiff).mockReturnValue({
|
|
||||||
isNew: false,
|
|
||||||
oldContent: 'old content',
|
|
||||||
newContent: 'new content',
|
|
||||||
hunks: [],
|
|
||||||
} as any);
|
|
||||||
vi.mocked(countChanges).mockReturnValue({ additions: 5, deletions: 3 });
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('+5 行');
|
|
||||||
expect(calls).toContain('-3 行');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 y 返回允许不记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 Y 返回允许并记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('Y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 n 返回拒绝不记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('n')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 N 返回拒绝并记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('N')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无效输入默认拒绝', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('invalid')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('超长 diff 被截断', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
// 模拟超过 50 行的 diff
|
|
||||||
const longDiff = Array(100).fill('line').join('\n');
|
|
||||||
const { formatDiff } = await import('../../../src/utils/diff.js');
|
|
||||||
vi.mocked(formatDiff).mockReturnValue(longDiff);
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptFileWrite(baseContext);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('省略');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promptFileEdit - 文件编辑提示', () => {
|
|
||||||
const baseContext: FilePermissionContext = {
|
|
||||||
operation: 'edit',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'edit_file',
|
|
||||||
oldContent: 'old text',
|
|
||||||
newContent: 'new text',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('无内容时使用简单确认', async () => {
|
|
||||||
const ctx: FilePermissionContext = {
|
|
||||||
...baseContext,
|
|
||||||
oldContent: undefined,
|
|
||||||
newContent: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileEdit(ctx);
|
|
||||||
|
|
||||||
expect(result.allow).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示编辑 diff', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptFileEdit(baseContext);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('文件编辑预览');
|
|
||||||
expect(calls).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户确认后返回决定', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('Y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileEdit(baseContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promptFilePermission - 统一入口', () => {
|
|
||||||
it('write 操作调用 promptFileWrite', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
|
||||||
|
|
||||||
const ctx: FilePermissionContext = {
|
|
||||||
operation: 'write',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'write_file',
|
|
||||||
newContent: 'same content',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await promptFilePermission(ctx);
|
|
||||||
|
|
||||||
// 内容相同直接允许
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('edit 操作调用 promptFileEdit', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const ctx: FilePermissionContext = {
|
|
||||||
operation: 'edit',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'edit_file',
|
|
||||||
oldContent: 'old',
|
|
||||||
newContent: 'new',
|
|
||||||
};
|
|
||||||
|
|
||||||
await promptFilePermission(ctx);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('文件编辑预览');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('其他操作使用简单确认', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const ctx: FilePermissionContext = {
|
|
||||||
operation: 'delete',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'delete_file',
|
|
||||||
};
|
|
||||||
|
|
||||||
await promptFilePermission(ctx);
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('文件操作确认');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('确认选项显示', () => {
|
|
||||||
it('显示所有选项', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptFileWrite({
|
|
||||||
operation: 'write',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'write_file',
|
|
||||||
newContent: 'new content',
|
|
||||||
});
|
|
||||||
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).toContain('[y]');
|
|
||||||
expect(calls).toContain('[Y]');
|
|
||||||
expect(calls).toContain('[n]');
|
|
||||||
expect(calls).toContain('[N]');
|
|
||||||
expect(calls).toContain('确认执行');
|
|
||||||
expect(calls).toContain('拒绝执行');
|
|
||||||
expect(calls).toContain('记住');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('输入处理', () => {
|
|
||||||
it('输入被 trim', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback(' y ')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptFileWrite({
|
|
||||||
operation: 'write',
|
|
||||||
path: '/test/file.ts',
|
|
||||||
workdir: '/test',
|
|
||||||
toolName: 'write_file',
|
|
||||||
newContent: 'new content',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { promptPermission, showPermissionDenied, showPermissionAllowed } from '../../../src/permission/prompt.js';
|
|
||||||
import type { PermissionContext } from '../../../src/permission/types.js';
|
|
||||||
|
|
||||||
// Mock readline
|
|
||||||
vi.mock('readline', () => ({
|
|
||||||
createInterface: vi.fn(() => ({
|
|
||||||
question: vi.fn(),
|
|
||||||
close: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock chalk
|
|
||||||
vi.mock('chalk', () => ({
|
|
||||||
default: {
|
|
||||||
yellow: (s: string) => s,
|
|
||||||
cyan: (s: string) => s,
|
|
||||||
white: (s: string) => s,
|
|
||||||
gray: (s: string) => s,
|
|
||||||
red: (s: string) => s,
|
|
||||||
green: (s: string) => s,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as readline from 'readline';
|
|
||||||
|
|
||||||
describe('Permission Prompt - 权限提示模块', () => {
|
|
||||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('showPermissionDenied - 显示权限被拒绝', () => {
|
|
||||||
it('显示命令和原因', () => {
|
|
||||||
showPermissionDenied('rm -rf /', '危险命令');
|
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('权限被拒绝'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('rm -rf /'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('危险命令'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('输出包含空行', () => {
|
|
||||||
showPermissionDenied('test', 'reason');
|
|
||||||
|
|
||||||
// 第一个和最后一个调用是空行
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('showPermissionAllowed - 显示权限允许', () => {
|
|
||||||
it('显示执行的命令', () => {
|
|
||||||
showPermissionAllowed('npm install');
|
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('执行'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('npm install'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promptPermission - 交互式权限提示', () => {
|
|
||||||
const mockContext: PermissionContext = {
|
|
||||||
command: 'git push',
|
|
||||||
workdir: '/project',
|
|
||||||
toolName: 'bash',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('用户输入 y 返回允许不记住', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
expect(mockRl.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 Y 返回允许并记住', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('Y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 n 返回拒绝不记住', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('n')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 N 返回拒绝并记住', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('N')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无效输入默认为拒绝', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('invalid')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('空输入默认为拒绝', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: false, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('带空格的输入会被 trim', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback(' y ')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(result).toEqual({ allow: true, remember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示命令和工作目录', async () => {
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptPermission(mockContext);
|
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('git push'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/project'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示外部路径警告', async () => {
|
|
||||||
const contextWithExternal: PermissionContext = {
|
|
||||||
...mockContext,
|
|
||||||
externalPaths: ['/etc/passwd', '/root/.ssh'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('n')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptPermission(contextWithExternal);
|
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('项目目录外的路径'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/etc/passwd'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('/root/.ssh'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示匹配模式', async () => {
|
|
||||||
const contextWithPatterns: PermissionContext = {
|
|
||||||
...mockContext,
|
|
||||||
patterns: ['*.js', '*.ts'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptPermission(contextWithPatterns);
|
|
||||||
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.js'));
|
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('*.ts'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不显示空的外部路径', async () => {
|
|
||||||
const contextEmptyExternal: PermissionContext = {
|
|
||||||
...mockContext,
|
|
||||||
externalPaths: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await promptPermission(contextEmptyExternal);
|
|
||||||
|
|
||||||
// 不应该显示外部路径相关的警告
|
|
||||||
const calls = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(calls).not.toContain('项目目录外的路径');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||||
computeDiff,
|
|
||||||
formatDiff,
|
|
||||||
countChanges,
|
|
||||||
formatEditDiff,
|
|
||||||
confirmFileChange,
|
|
||||||
} from '../../../src/utils/diff.js';
|
|
||||||
|
|
||||||
// Mock readline
|
|
||||||
vi.mock('readline', () => ({
|
|
||||||
createInterface: vi.fn(() => ({
|
|
||||||
question: vi.fn(),
|
|
||||||
close: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fs/promises
|
|
||||||
vi.mock('fs/promises', () => ({
|
|
||||||
readFile: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock chalk (保留原始功能以便测试输出格式)
|
|
||||||
vi.mock('chalk', () => ({
|
|
||||||
default: {
|
|
||||||
yellow: (s: string) => `[yellow]${s}[/yellow]`,
|
|
||||||
cyan: (s: string) => `[cyan]${s}[/cyan]`,
|
|
||||||
white: (s: string) => `[white]${s}[/white]`,
|
|
||||||
gray: (s: string) => `[gray]${s}[/gray]`,
|
|
||||||
red: (s: string) => `[red]${s}[/red]`,
|
|
||||||
green: (s: string) => `[green]${s}[/green]`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as readline from 'readline';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
|
|
||||||
describe('Diff - 差异比较扩展测试', () => {
|
describe('Diff - 差异比较扩展测试', () => {
|
||||||
describe('computeDiff - 计算 diff', () => {
|
describe('computeDiff - 计算 diff', () => {
|
||||||
@@ -120,45 +86,6 @@ describe('Diff - 差异比较扩展测试', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatDiff - 格式化 diff', () => {
|
|
||||||
it('新文件显示 +++ 新文件标记', () => {
|
|
||||||
const diff = computeDiff(null, 'new content');
|
|
||||||
const formatted = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(formatted).toContain('新文件');
|
|
||||||
expect(formatted).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('修改文件显示 --- 和 +++ 标记', () => {
|
|
||||||
const diff = computeDiff('old', 'new');
|
|
||||||
const formatted = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(formatted).toContain('原文件');
|
|
||||||
expect(formatted).toContain('修改后');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示 hunk 头部 @@ 信息', () => {
|
|
||||||
const diff = computeDiff('old', 'new');
|
|
||||||
const formatted = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(formatted).toContain('@@');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('添加行使用 + 前缀', () => {
|
|
||||||
const diff = computeDiff('', 'added line');
|
|
||||||
const formatted = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(formatted).toContain('+ added line');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('删除行使用 - 前缀', () => {
|
|
||||||
const diff = computeDiff('removed line', '');
|
|
||||||
const formatted = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(formatted).toContain('- removed line');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('countChanges - 统计变更', () => {
|
describe('countChanges - 统计变更', () => {
|
||||||
it('统计添加行数', () => {
|
it('统计添加行数', () => {
|
||||||
const diff = computeDiff(null, 'line1\nline2\nline3');
|
const diff = computeDiff(null, 'line1\nline2\nline3');
|
||||||
@@ -191,173 +118,4 @@ describe('Diff - 差异比较扩展测试', () => {
|
|||||||
expect(changes.additions + changes.deletions).toBe(0);
|
expect(changes.additions + changes.deletions).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatEditDiff - 编辑 diff 格式化', () => {
|
|
||||||
it('显示删除和添加内容', () => {
|
|
||||||
const formatted = formatEditDiff('old text', 'new text');
|
|
||||||
|
|
||||||
expect(formatted).toContain('old text');
|
|
||||||
expect(formatted).toContain('new text');
|
|
||||||
expect(formatted).toContain('-');
|
|
||||||
expect(formatted).toContain('+');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('处理多行内容', () => {
|
|
||||||
const formatted = formatEditDiff('old1\nold2', 'new1\nnew2');
|
|
||||||
|
|
||||||
expect(formatted).toContain('old1');
|
|
||||||
expect(formatted).toContain('old2');
|
|
||||||
expect(formatted).toContain('new1');
|
|
||||||
expect(formatted).toContain('new2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('包含变更内容标题', () => {
|
|
||||||
const formatted = formatEditDiff('old', 'new');
|
|
||||||
|
|
||||||
expect(formatted).toContain('变更内容');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('confirmFileChange - 文件变更确认', () => {
|
|
||||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('内容相同直接返回确认', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('same content');
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'same content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(true);
|
|
||||||
expect(result.remember).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('新文件(读取失败)显示 diff', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 y 确认写入', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(true);
|
|
||||||
expect(result.remember).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 Y 确认并记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('Y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(true);
|
|
||||||
expect(result.remember).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 n 取消操作', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('n')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(false);
|
|
||||||
expect(result.remember).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户输入 N 取消并记住', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('N')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(false);
|
|
||||||
expect(result.remember).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无效输入默认取消', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('invalid')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
const result = await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
expect(result.confirmed).toBe(false);
|
|
||||||
expect(result.remember).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示变更预览信息', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await confirmFileChange('/test/file.ts', 'new content', 'write');
|
|
||||||
|
|
||||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(output).toContain('文件变更预览');
|
|
||||||
expect(output).toContain('写入文件');
|
|
||||||
expect(output).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('显示编辑操作类型', async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue('old content');
|
|
||||||
|
|
||||||
const mockRl = {
|
|
||||||
question: vi.fn((_, callback) => callback('y')),
|
|
||||||
close: vi.fn(),
|
|
||||||
};
|
|
||||||
vi.mocked(readline.createInterface).mockReturnValue(mockRl as any);
|
|
||||||
|
|
||||||
await confirmFileChange('/test/file.ts', 'new content', 'edit');
|
|
||||||
|
|
||||||
const output = consoleLogSpy.mock.calls.flat().join('\n');
|
|
||||||
expect(output).toContain('编辑文件');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { computeDiff, countChanges, formatEditDiff, formatDiff } from '../../../src/utils/diff.js';
|
import { computeDiff, countChanges } from '../../../src/utils/diff.js';
|
||||||
|
|
||||||
describe('computeDiff - 计算文件差异', () => {
|
describe('computeDiff - 计算文件差异', () => {
|
||||||
describe('新文件', () => {
|
describe('新文件', () => {
|
||||||
@@ -156,31 +156,6 @@ describe('countChanges - 统计变更数量', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatEditDiff - 格式化编辑差异', () => {
|
|
||||||
it('显示删除和新增内容', () => {
|
|
||||||
const result = formatEditDiff('old text', 'new text');
|
|
||||||
|
|
||||||
expect(result).toContain('变更内容');
|
|
||||||
expect(result).toContain('old text');
|
|
||||||
expect(result).toContain('new text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('多行内容正确显示', () => {
|
|
||||||
const result = formatEditDiff('line1\nline2', 'new1\nnew2\nnew3');
|
|
||||||
|
|
||||||
expect(result).toContain('line1');
|
|
||||||
expect(result).toContain('line2');
|
|
||||||
expect(result).toContain('new1');
|
|
||||||
expect(result).toContain('new2');
|
|
||||||
expect(result).toContain('new3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('空内容处理', () => {
|
|
||||||
const result = formatEditDiff('', 'new');
|
|
||||||
|
|
||||||
expect(result).toContain('new');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DiffResult 结构', () => {
|
describe('DiffResult 结构', () => {
|
||||||
it('包含所有必要字段', () => {
|
it('包含所有必要字段', () => {
|
||||||
@@ -248,55 +223,6 @@ describe('LCS 算法测试', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatDiff - 格式化 diff 输出', () => {
|
|
||||||
it('新文件显示新增标记', () => {
|
|
||||||
const diff = computeDiff(null, 'line1\nline2');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(result).toContain('新文件');
|
|
||||||
expect(result).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('修改文件显示原文件和修改后标记', () => {
|
|
||||||
const diff = computeDiff('old', 'new');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(result).toContain('原文件');
|
|
||||||
expect(result).toContain('修改后');
|
|
||||||
expect(result).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hunk 头部显示正确的行号范围', () => {
|
|
||||||
const diff = computeDiff('line1\nline2', 'line1\nnew\nline2');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(result).toContain('@@');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('新增行显示 + 前缀', () => {
|
|
||||||
const diff = computeDiff(null, 'added line');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(result).toContain('+');
|
|
||||||
expect(result).toContain('added line');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('删除行显示 - 前缀', () => {
|
|
||||||
const diff = computeDiff('removed line', 'new line');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
expect(result).toContain('-');
|
|
||||||
expect(result).toContain('removed line');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('空 diff 返回基本格式', () => {
|
|
||||||
const diff = computeDiff('same', 'same');
|
|
||||||
const result = formatDiff(diff, '/test/file.ts');
|
|
||||||
|
|
||||||
// 没有 hunks 时只有头部
|
|
||||||
expect(result).toContain('/test/file.ts');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('实际代码场景', () => {
|
describe('实际代码场景', () => {
|
||||||
it('函数修改', () => {
|
it('函数修改', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { SessionStatus } from '../types.js';
|
|||||||
import { getSessionManager } from '../session/manager.js';
|
import { getSessionManager } from '../session/manager.js';
|
||||||
import { broadcastToSession } from '../ws.js';
|
import { broadcastToSession } from '../ws.js';
|
||||||
import { emitStatusEvent, emitLogEvent } from '../sse.js';
|
import { emitStatusEvent, emitLogEvent } from '../sse.js';
|
||||||
|
import { createServerPermissionCallback } from '../permission/handler.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
|
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
|
||||||
@@ -41,6 +42,13 @@ interface ToolRegistry {
|
|||||||
getAllTools(): unknown[];
|
getAllTools(): unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission Manager 接口
|
||||||
|
*/
|
||||||
|
interface PermissionManager {
|
||||||
|
setAskCallback(callback: (ctx: unknown) => Promise<{ allow: boolean; remember?: boolean }>): void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core 模块接口
|
* Core 模块接口
|
||||||
*/
|
*/
|
||||||
@@ -48,6 +56,7 @@ interface CoreModule {
|
|||||||
Agent: AgentConstructor;
|
Agent: AgentConstructor;
|
||||||
toolRegistry: ToolRegistry;
|
toolRegistry: ToolRegistry;
|
||||||
loadConfig: () => unknown;
|
loadConfig: () => unknown;
|
||||||
|
getPermissionManager: (projectRoot?: string) => PermissionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -114,6 +123,10 @@ export function getOrCreateAgent(sessionId: string): AgentInstance | null {
|
|||||||
const agent = new coreModule.Agent(config);
|
const agent = new coreModule.Agent(config);
|
||||||
agent.setRegistry(coreModule.toolRegistry);
|
agent.setRegistry(coreModule.toolRegistry);
|
||||||
|
|
||||||
|
// 设置权限回调,通过 WebSocket 请求用户确认
|
||||||
|
const permissionManager = coreModule.getPermissionManager();
|
||||||
|
permissionManager.setAskCallback(createServerPermissionCallback(sessionId));
|
||||||
|
|
||||||
agentCache.set(sessionId, agent);
|
agentCache.set(sessionId, agent);
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Server 端权限处理器
|
||||||
|
* 通过 WebSocket 发送权限请求并等待客户端响应
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { broadcastToSession } from '../ws.js';
|
||||||
|
import type {
|
||||||
|
PermissionType,
|
||||||
|
PermissionRequestPayload,
|
||||||
|
PermissionRequestContext,
|
||||||
|
ServerMessage,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限决策结果
|
||||||
|
*/
|
||||||
|
export interface PermissionDecision {
|
||||||
|
allow: boolean;
|
||||||
|
remember?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限上下文(来自 core 模块)
|
||||||
|
*/
|
||||||
|
export interface PermissionContext {
|
||||||
|
command: string;
|
||||||
|
workdir: string;
|
||||||
|
patterns?: string[];
|
||||||
|
externalPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待中的权限请求
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (decision: PermissionDecision) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequests = new Map<string, PendingRequest>();
|
||||||
|
|
||||||
|
// 默认超时时间(60秒)
|
||||||
|
const PERMISSION_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从命令或上下文检测权限类型
|
||||||
|
*/
|
||||||
|
function detectPermissionType(ctx: PermissionContext): PermissionType {
|
||||||
|
const command = ctx.command.toLowerCase();
|
||||||
|
|
||||||
|
// 检测 git 操作
|
||||||
|
if (command.startsWith('git ')) {
|
||||||
|
return 'git';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测文件操作
|
||||||
|
const fileOps = ['read', 'write', 'edit', 'delete', 'move', 'copy', 'mkdir'];
|
||||||
|
for (const op of fileOps) {
|
||||||
|
if (command.startsWith(`${op} `)) {
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测 web 操作
|
||||||
|
if (command.includes('fetch') || command.includes('http')) {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认为 bash
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建权限请求上下文
|
||||||
|
*/
|
||||||
|
function buildRequestContext(ctx: PermissionContext): PermissionRequestContext {
|
||||||
|
const permType = detectPermissionType(ctx);
|
||||||
|
|
||||||
|
switch (permType) {
|
||||||
|
case 'file': {
|
||||||
|
const parts = ctx.command.split(' ');
|
||||||
|
const operation = parts[0];
|
||||||
|
const path = parts.slice(1).join(' ');
|
||||||
|
return {
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
patterns: ctx.patterns,
|
||||||
|
externalPaths: ctx.externalPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'git': {
|
||||||
|
const gitOp = ctx.command.replace(/^git\s+/, '').split(' ')[0];
|
||||||
|
return {
|
||||||
|
command: ctx.command,
|
||||||
|
gitOperation: gitOp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'web': {
|
||||||
|
return {
|
||||||
|
command: ctx.command,
|
||||||
|
query: ctx.command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
command: ctx.command,
|
||||||
|
patterns: ctx.patterns,
|
||||||
|
externalPaths: ctx.externalPaths,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Server 端权限回调函数
|
||||||
|
* 用于在 Agent 创建时设置
|
||||||
|
*/
|
||||||
|
export function createServerPermissionCallback(sessionId: string) {
|
||||||
|
return async (ctx: unknown): Promise<PermissionDecision> => {
|
||||||
|
const permCtx = ctx as PermissionContext;
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const permissionType = detectPermissionType(permCtx);
|
||||||
|
const context = buildRequestContext(permCtx);
|
||||||
|
|
||||||
|
// 构建请求 payload
|
||||||
|
const payload: PermissionRequestPayload = {
|
||||||
|
requestId,
|
||||||
|
permissionType,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送权限请求到客户端
|
||||||
|
const message: ServerMessage = {
|
||||||
|
type: 'permission_request',
|
||||||
|
sessionId,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
broadcastToSession(sessionId, message);
|
||||||
|
|
||||||
|
// 等待响应(带超时)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRequests.delete(requestId);
|
||||||
|
// 超时默认拒绝
|
||||||
|
resolve({ allow: false, remember: false });
|
||||||
|
}, PERMISSION_TIMEOUT);
|
||||||
|
|
||||||
|
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理权限响应
|
||||||
|
* 由 WebSocket 消息处理器调用
|
||||||
|
*/
|
||||||
|
export function handlePermissionResponse(
|
||||||
|
requestId: string,
|
||||||
|
allow: boolean,
|
||||||
|
remember?: boolean
|
||||||
|
): boolean {
|
||||||
|
const pending = pendingRequests.get(requestId);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
console.warn(`[Permission] No pending request found for: ${requestId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingRequests.delete(requestId);
|
||||||
|
pending.resolve({ allow, remember });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消会话的所有待处理权限请求
|
||||||
|
* 用于会话断开时清理
|
||||||
|
*/
|
||||||
|
export function cancelPendingRequests(sessionId: string): void {
|
||||||
|
// 由于我们没有存储 sessionId -> requestId 的映射,
|
||||||
|
// 这个函数目前只是一个占位符
|
||||||
|
// 在实际使用中,如果需要按会话取消请求,需要维护额外的映射
|
||||||
|
console.log(`[Permission] Cancelling pending requests for session: ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取待处理请求数量(用于调试)
|
||||||
|
*/
|
||||||
|
export function getPendingRequestCount(): number {
|
||||||
|
return pendingRequests.size;
|
||||||
|
}
|
||||||
@@ -89,12 +89,16 @@ export type Tool = z.infer<typeof ToolSchema>;
|
|||||||
|
|
||||||
// 客户端发送的消息
|
// 客户端发送的消息
|
||||||
export interface ClientMessage {
|
export interface ClientMessage {
|
||||||
type: 'message' | 'cancel' | 'tool_response';
|
type: 'message' | 'cancel' | 'tool_response' | 'permission_response';
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
payload?: {
|
payload?: {
|
||||||
content?: string;
|
content?: string;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
approved?: boolean;
|
approved?: boolean;
|
||||||
|
// Permission response fields
|
||||||
|
requestId?: string;
|
||||||
|
allow?: boolean;
|
||||||
|
remember?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +113,70 @@ export interface ServerMessage {
|
|||||||
| 'done'
|
| 'done'
|
||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'session_updated';
|
| 'session_updated'
|
||||||
|
| 'permission_request';
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Permission 相关 ============
|
||||||
|
|
||||||
|
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限请求上下文
|
||||||
|
*/
|
||||||
|
export interface PermissionRequestContext {
|
||||||
|
command?: string; // bash 命令
|
||||||
|
operation?: string; // 文件操作类型: read/write/edit/delete
|
||||||
|
path?: string; // 文件路径
|
||||||
|
gitOperation?: string; // git 操作
|
||||||
|
query?: string; // web 查询
|
||||||
|
patterns?: string[]; // 匹配模式
|
||||||
|
externalPaths?: string[]; // 外部路径
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff 信息(文件写入/编辑时)
|
||||||
|
*/
|
||||||
|
export interface DiffHunkInfo {
|
||||||
|
oldStart: number;
|
||||||
|
oldCount: number;
|
||||||
|
newStart: number;
|
||||||
|
newCount: number;
|
||||||
|
lines: Array<{
|
||||||
|
type: 'add' | 'remove' | 'context';
|
||||||
|
lineNumber: number | null;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffInfo {
|
||||||
|
isNew: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
hunks: DiffHunkInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限请求消息 payload
|
||||||
|
*/
|
||||||
|
export interface PermissionRequestPayload {
|
||||||
|
requestId: string;
|
||||||
|
permissionType: PermissionType;
|
||||||
|
context: PermissionRequestContext;
|
||||||
|
diff?: DiffInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限响应消息 payload
|
||||||
|
*/
|
||||||
|
export interface PermissionResponsePayload {
|
||||||
|
requestId: string;
|
||||||
|
allow: boolean;
|
||||||
|
remember?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ SSE 事件 ============
|
// ============ SSE 事件 ============
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { WSContext } from 'hono/ws';
|
import type { WSContext } from 'hono/ws';
|
||||||
import { getSessionManager } from './session/manager.js';
|
import { getSessionManager } from './session/manager.js';
|
||||||
import { processMessage, cancelProcessing } from './agent/index.js';
|
import { processMessage, cancelProcessing } from './agent/index.js';
|
||||||
|
import { handlePermissionResponse } from './permission/handler.js';
|
||||||
import type { ClientMessage, ServerMessage } from './types.js';
|
import type { ClientMessage, ServerMessage } from './types.js';
|
||||||
|
|
||||||
// 存储活跃的 WebSocket 连接
|
// 存储活跃的 WebSocket 连接
|
||||||
@@ -143,6 +144,18 @@ export async function handleWebSocketMessage(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'permission_response': {
|
||||||
|
// 处理权限确认响应
|
||||||
|
const { requestId, allow, remember } = message.payload || {};
|
||||||
|
if (requestId) {
|
||||||
|
const handled = handlePermissionResponse(requestId, allow ?? false, remember);
|
||||||
|
if (!handled) {
|
||||||
|
console.warn(`[WS] Permission response for unknown request: ${requestId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* PermissionDialog Component
|
||||||
|
*
|
||||||
|
* Shows permission confirmation dialogs for bash commands, file operations, etc.
|
||||||
|
* Integrates with WebSocket for real-time permission requests from the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Terminal,
|
||||||
|
FileEdit,
|
||||||
|
GitBranch,
|
||||||
|
Globe,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
||||||
|
import { Button } from '../primitives/Button';
|
||||||
|
|
||||||
|
// Permission types
|
||||||
|
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
|
||||||
|
|
||||||
|
// Permission request context
|
||||||
|
export interface PermissionRequestContext {
|
||||||
|
command?: string;
|
||||||
|
operation?: string;
|
||||||
|
path?: string;
|
||||||
|
gitOperation?: string;
|
||||||
|
query?: string;
|
||||||
|
patterns?: string[];
|
||||||
|
externalPaths?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff line for file operations
|
||||||
|
interface DiffLine {
|
||||||
|
type: 'add' | 'remove' | 'context';
|
||||||
|
lineNumber: number | null;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff hunk
|
||||||
|
interface DiffHunk {
|
||||||
|
oldStart: number;
|
||||||
|
oldCount: number;
|
||||||
|
newStart: number;
|
||||||
|
newCount: number;
|
||||||
|
lines: DiffLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff info for file operations
|
||||||
|
export interface DiffInfo {
|
||||||
|
isNew: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
hunks: DiffHunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission request payload
|
||||||
|
export interface PermissionRequest {
|
||||||
|
requestId: string;
|
||||||
|
permissionType: PermissionType;
|
||||||
|
context: PermissionRequestContext;
|
||||||
|
diff?: DiffInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionDialogProps {
|
||||||
|
request: PermissionRequest;
|
||||||
|
onAllow: (requestId: string, remember: boolean) => void;
|
||||||
|
onDeny: (requestId: string, remember: boolean) => void;
|
||||||
|
responsive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon component based on permission type
|
||||||
|
function getPermissionIcon(type: PermissionType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'bash':
|
||||||
|
return <Terminal size={24} className="text-yellow-400" />;
|
||||||
|
case 'file':
|
||||||
|
return <FileEdit size={24} className="text-blue-400" />;
|
||||||
|
case 'git':
|
||||||
|
return <GitBranch size={24} className="text-purple-400" />;
|
||||||
|
case 'web':
|
||||||
|
return <Globe size={24} className="text-green-400" />;
|
||||||
|
default:
|
||||||
|
return <Shield size={24} className="text-gray-400" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title based on permission type
|
||||||
|
function getPermissionTitle(type: PermissionType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'bash':
|
||||||
|
return 'Execute Command';
|
||||||
|
case 'file':
|
||||||
|
return 'File Operation';
|
||||||
|
case 'git':
|
||||||
|
return 'Git Operation';
|
||||||
|
case 'web':
|
||||||
|
return 'Web Access';
|
||||||
|
default:
|
||||||
|
return 'Permission Required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render diff content
|
||||||
|
function DiffViewer({ diff }: { diff: DiffInfo }) {
|
||||||
|
if (!diff.hunks || diff.hunks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-gray-900/50 border-b border-gray-700">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{diff.isNew ? 'New file' : 'Changes'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-green-400">+{diff.additions}</span>
|
||||||
|
<span className="text-red-400">-{diff.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-auto">
|
||||||
|
<pre className="text-xs font-mono">
|
||||||
|
{diff.hunks.map((hunk, hunkIndex) => (
|
||||||
|
<div key={hunkIndex}>
|
||||||
|
<div className="px-3 py-1 bg-blue-500/10 text-blue-400 border-y border-gray-700/50">
|
||||||
|
@@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@
|
||||||
|
</div>
|
||||||
|
{hunk.lines.map((line, lineIndex) => {
|
||||||
|
let className = 'px-3 py-0.5 ';
|
||||||
|
let prefix = ' ';
|
||||||
|
|
||||||
|
if (line.type === 'add') {
|
||||||
|
className += 'bg-green-500/10 text-green-400';
|
||||||
|
prefix = '+';
|
||||||
|
} else if (line.type === 'remove') {
|
||||||
|
className += 'bg-red-500/10 text-red-400';
|
||||||
|
prefix = '-';
|
||||||
|
} else {
|
||||||
|
className += 'text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={lineIndex} className={className}>
|
||||||
|
<span className="select-none opacity-50 mr-2">{prefix}</span>
|
||||||
|
{line.content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionDialog({
|
||||||
|
request,
|
||||||
|
onAllow,
|
||||||
|
onDeny,
|
||||||
|
responsive = false,
|
||||||
|
}: PermissionDialogProps) {
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
|
const { requestId, permissionType, context, diff } = request;
|
||||||
|
|
||||||
|
const handleAllow = () => {
|
||||||
|
onAllow(requestId, remember);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeny = () => {
|
||||||
|
onDeny(requestId, remember);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format context for display
|
||||||
|
const renderContext = () => {
|
||||||
|
switch (permissionType) {
|
||||||
|
case 'bash':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-400">Command:</div>
|
||||||
|
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-yellow-300 break-all">
|
||||||
|
{context.command}
|
||||||
|
</code>
|
||||||
|
{context.externalPaths && context.externalPaths.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 mt-3 p-2 bg-yellow-500/10 rounded-lg">
|
||||||
|
<AlertTriangle size={16} className="text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-yellow-300">
|
||||||
|
<div className="font-medium">External paths detected:</div>
|
||||||
|
<div className="mt-1 text-yellow-400/80">
|
||||||
|
{context.externalPaths.map((p, i) => (
|
||||||
|
<div key={i} className="font-mono">{p}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'file':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Operation:</span>
|
||||||
|
<span className={cn(
|
||||||
|
'px-2 py-0.5 rounded text-xs font-medium',
|
||||||
|
context.operation === 'delete' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
context.operation === 'write' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
'bg-blue-500/20 text-blue-400'
|
||||||
|
)}>
|
||||||
|
{context.operation?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">Path:</div>
|
||||||
|
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-blue-300 break-all">
|
||||||
|
{context.path}
|
||||||
|
</code>
|
||||||
|
{diff && <DiffViewer diff={diff} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'git':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">Git operation:</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
|
||||||
|
{context.gitOperation?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{context.command && (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-gray-400">Command:</div>
|
||||||
|
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-purple-300 break-all">
|
||||||
|
{context.command}
|
||||||
|
</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'web':
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-400">Request:</div>
|
||||||
|
<code className="block px-3 py-2 bg-gray-900 rounded-lg font-mono text-sm text-green-300 break-all">
|
||||||
|
{context.query || context.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
<pre className="bg-gray-900 p-3 rounded-lg overflow-auto">
|
||||||
|
{JSON.stringify(context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={modalOverlay}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/50 flex z-50',
|
||||||
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={modalContent}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={smoothTransition}
|
||||||
|
className={cn(
|
||||||
|
'bg-gray-800 overflow-hidden flex flex-col',
|
||||||
|
responsive
|
||||||
|
? 'w-full md:w-full md:max-w-lg md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||||
|
: 'rounded-lg w-full max-w-lg mx-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
|
||||||
|
{responsive && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||||
|
)}
|
||||||
|
<div className={cn('flex items-center gap-3', responsive && 'mt-2 md:mt-0')}>
|
||||||
|
<div className="p-2 rounded-lg bg-gray-900">
|
||||||
|
{getPermissionIcon(permissionType)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{getPermissionTitle(permissionType)}</h2>
|
||||||
|
<p className="text-xs text-gray-500">AI is requesting permission</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleDeny}
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]">
|
||||||
|
{renderContext()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col gap-3 border-t border-gray-700',
|
||||||
|
responsive ? 'px-4 py-4 safe-area-pb' : 'px-5 py-4'
|
||||||
|
)}>
|
||||||
|
{/* Remember checkbox */}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
Remember for this session
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 text-red-400 border-red-400/50 hover:border-red-400 hover:bg-red-400/10"
|
||||||
|
onClick={handleDeny}
|
||||||
|
>
|
||||||
|
<X size={16} className="mr-2" />
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleAllow}
|
||||||
|
>
|
||||||
|
<Check size={16} className="mr-2" />
|
||||||
|
Allow
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createWebSocket, getMessages, type Message } from '../api/client.js';
|
import { createWebSocket, getMessages, type Message } from '../api/client.js';
|
||||||
|
import type { PermissionRequest } from '../components/PermissionDialog.js';
|
||||||
|
|
||||||
interface UseChatOptions {
|
interface UseChatOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -19,6 +20,7 @@ interface ChatState {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
streamingContent: string;
|
streamingContent: string;
|
||||||
|
permissionRequest: PermissionRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) {
|
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) {
|
||||||
@@ -27,10 +29,11 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
isConnected: false,
|
isConnected: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
streamingContent: '',
|
streamingContent: '',
|
||||||
|
permissionRequest: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
// 标记是否正在主动关闭连接(切换 session 时)
|
// 标记是否正在主动关闭连接(切换 session 时)
|
||||||
@@ -144,6 +147,16 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
onSessionUpdatedRef.current?.(message.payload.id, message.payload.name);
|
onSessionUpdatedRef.current?.(message.payload.id, message.payload.name);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'permission_request':
|
||||||
|
// 权限请求
|
||||||
|
if (message.payload) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
permissionRequest: message.payload as PermissionRequest,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略解析错误
|
// 忽略解析错误
|
||||||
@@ -188,6 +201,44 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
|
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// 发送权限响应
|
||||||
|
const respondToPermission = useCallback(
|
||||||
|
(requestId: string, allow: boolean, remember?: boolean) => {
|
||||||
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
|
onErrorRef.current?.(new Error('WebSocket not connected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsRef.current.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'permission_response',
|
||||||
|
sessionId,
|
||||||
|
payload: { requestId, allow, remember },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除权限请求状态
|
||||||
|
setState((prev) => ({ ...prev, permissionRequest: null }));
|
||||||
|
},
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 允许权限请求
|
||||||
|
const allowPermission = useCallback(
|
||||||
|
(requestId: string, remember?: boolean) => {
|
||||||
|
respondToPermission(requestId, true, remember);
|
||||||
|
},
|
||||||
|
[respondToPermission]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拒绝权限请求
|
||||||
|
const denyPermission = useCallback(
|
||||||
|
(requestId: string, remember?: boolean) => {
|
||||||
|
respondToPermission(requestId, false, remember);
|
||||||
|
},
|
||||||
|
[respondToPermission]
|
||||||
|
);
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
@@ -197,6 +248,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
isConnected: false,
|
isConnected: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
streamingContent: '',
|
streamingContent: '',
|
||||||
|
permissionRequest: null,
|
||||||
});
|
});
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
|
||||||
@@ -225,5 +277,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
cancelProcessing,
|
cancelProcessing,
|
||||||
reload: loadMessages,
|
reload: loadMessages,
|
||||||
|
allowPermission,
|
||||||
|
denyPermission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js';
|
|||||||
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
export { CheckpointPanel } from './components/CheckpointPanel.js';
|
||||||
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.js';
|
||||||
export { RestoreDialog } from './components/RestoreDialog.js';
|
export { RestoreDialog } from './components/RestoreDialog.js';
|
||||||
|
export { PermissionDialog } from './components/PermissionDialog.js';
|
||||||
|
export type { PermissionRequest, PermissionType, PermissionRequestContext, DiffInfo as PermissionDiffInfo } from './components/PermissionDialog.js';
|
||||||
export { Sidebar } from './components/Sidebar.js';
|
export { Sidebar } from './components/Sidebar.js';
|
||||||
export { FileBrowser } from './components/FileBrowser.js';
|
export { FileBrowser } from './components/FileBrowser.js';
|
||||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
StreamingMessage,
|
StreamingMessage,
|
||||||
TypingIndicator,
|
TypingIndicator,
|
||||||
ChatInput,
|
ChatInput,
|
||||||
|
PermissionDialog,
|
||||||
} from '@ai-assistant/ui';
|
} from '@ai-assistant/ui';
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
@@ -50,6 +51,9 @@ export function ChatPage({
|
|||||||
streamingContent,
|
streamingContent,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
cancelProcessing,
|
cancelProcessing,
|
||||||
|
permissionRequest,
|
||||||
|
allowPermission,
|
||||||
|
denyPermission,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
sessionId,
|
sessionId,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -264,6 +268,16 @@ export function ChatPage({
|
|||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
responsive={responsive}
|
responsive={responsive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Permission Dialog */}
|
||||||
|
{permissionRequest && (
|
||||||
|
<PermissionDialog
|
||||||
|
request={permissionRequest}
|
||||||
|
onAllow={(requestId, remember) => allowPermission(requestId, remember)}
|
||||||
|
onDeny={(requestId, remember) => denyPermission(requestId, remember)}
|
||||||
|
responsive={responsive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user