From 1d69fd876d065bd91155c4f8437c23ca89723a51 Mon Sep 17 00:00:00 2001 From: kurihada Date: Sat, 13 Dec 2025 01:09:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(permission):=20=E5=AE=9E=E7=8E=B0=20WebSoc?= =?UTF-8?q?ket=20=E6=9D=83=E9=99=90=E7=A1=AE=E8=AE=A4=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构权限系统,将终端 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 页面集成权限弹窗 --- packages/core/package.json | 1 - packages/core/src/index.ts | 13 +- packages/core/src/permission/checkers/file.ts | 24 +- packages/core/src/permission/file-prompt.ts | 186 -------- packages/core/src/permission/index.ts | 9 +- packages/core/src/permission/prompt.ts | 79 ---- packages/core/src/utils/config.ts | 161 ------- packages/core/src/utils/diff.ts | 159 +------ .../tests/unit/permission/file-prompt.test.ts | 399 ------------------ .../core/tests/unit/permission/prompt.test.ts | 226 ---------- .../tests/unit/utils/diff-extended.test.ts | 246 +---------- packages/core/tests/unit/utils/diff.test.ts | 76 +--- packages/server/src/agent/adapter.ts | 13 + packages/server/src/permission/handler.ts | 192 +++++++++ packages/server/src/types.ts | 67 ++- packages/server/src/ws.ts | 13 + .../ui/src/components/PermissionDialog.tsx | 363 ++++++++++++++++ packages/ui/src/hooks/useChat.ts | 56 ++- packages/ui/src/index.ts | 2 + packages/web/src/pages/Chat.tsx | 14 + 20 files changed, 739 insertions(+), 1560 deletions(-) delete mode 100644 packages/core/src/permission/file-prompt.ts delete mode 100644 packages/core/src/permission/prompt.ts delete mode 100644 packages/core/tests/unit/permission/file-prompt.test.ts delete mode 100644 packages/core/tests/unit/permission/prompt.test.ts create mode 100644 packages/server/src/permission/handler.ts create mode 100644 packages/ui/src/components/PermissionDialog.tsx diff --git a/packages/core/package.json b/packages/core/package.json index 881aafa..984d22a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,7 +52,6 @@ "@ai-sdk/openai": "^2.0.80", "@tavily/core": "^0.6.0", "ai": "^5.0.108", - "chalk": "^5.3.0", "js-yaml": "^4.1.1", "minimatch": "^10.1.1", "nanoid": "^5.1.6", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec3eaa0..cf8bf17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { Agent } from './core/agent.js'; export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js'; -export { loadConfig, initConfig } from './utils/config.js'; +export { loadConfig, saveConfig, getConfig, loadVisionConfig } from './utils/config.js'; +export type { VisionConfig } from './utils/config.js'; export { SessionStorage } from './session/storage.js'; export { SessionManager } from './session/index.js'; export type { SessionData, SessionSummary } from './session/types.js'; @@ -9,7 +10,15 @@ export type { SessionData, SessionSummary } from './session/types.js'; export type { UserInput } from './types/index.js'; // Permission -export { getPermissionManager, promptPermission } from './permission/index.js'; +export { getPermissionManager } from './permission/index.js'; +export type { + PermissionContext, + PermissionDecision, + PermissionCheckResult, + FilePermissionContext, + GitPermissionContext, + WebPermissionContext, +} from './permission/index.js'; // LSP export { initLSP, shutdownLSP } from './lsp/index.js'; diff --git a/packages/core/src/permission/checkers/file.ts b/packages/core/src/permission/checkers/file.ts index 0ecb847..82cfbcd 100644 --- a/packages/core/src/permission/checkers/file.ts +++ b/packages/core/src/permission/checkers/file.ts @@ -9,7 +9,6 @@ import type { PermissionContext, } from '../types.js'; import type { PermissionChecker } from './base.js'; -import { promptFilePermission } from '../file-prompt.js'; const CONFIG_DIR = path.join(os.homedir(), '.ai-terminal-assistant'); const FILE_PERMISSION_FILE = path.join(CONFIG_DIR, 'file-permissions.json'); @@ -252,28 +251,7 @@ export class FilePermissionChecker implements PermissionChecker { sessionKey: string, reason: string ): Promise { - // 对于 write/edit 操作,如果有内容信息,使用 diff 显示 - if ((ctx.operation === 'write' || ctx.operation === 'edit') && ctx.newContent !== undefined) { - // 更新 ctx 中的路径为绝对路径 - const ctxWithAbsPath: FilePermissionContext = { - ...ctx, - path: absolutePath, - }; - - const decision = await promptFilePermission(ctxWithAbsPath); - - if (decision.remember) { - this.sessionPermissions.set(sessionKey, decision.allow ? 'allow' : 'deny'); - } - - return { - allowed: decision.allow, - action: decision.allow ? 'allow' : 'deny', - reason: decision.allow ? '用户允许' : '用户拒绝', - }; - } - - // 其他操作使用原有的回调 + // 如果没有回调,返回需要确认的状态 if (!this.askCallback) { return { allowed: false, diff --git a/packages/core/src/permission/file-prompt.ts b/packages/core/src/permission/file-prompt.ts deleted file mode 100644 index 0afd105..0000000 --- a/packages/core/src/permission/file-prompt.ts +++ /dev/null @@ -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 { - const { path: filePath, newContent } = ctx; - - if (!newContent) { - // 没有内容,使用简单确认 - return promptSimpleConfirm(ctx); - } - - // 读取原文件内容 - let oldContent: string | null = null; - try { - oldContent = await fs.readFile(filePath, 'utf-8'); - } catch { - // 文件不存在,是新文件 - } - - // 如果内容相同,直接允许 - if (oldContent === newContent) { - return { allow: true, remember: false }; - } - - // 计算 diff - const diff = computeDiff(oldContent, newContent); - const changes = countChanges(diff); - - // 显示 diff - console.log(''); - console.log(chalk.yellow('📝 文件写入预览')); - console.log(chalk.cyan('文件: ') + chalk.white(filePath)); - - if (diff.isNew) { - console.log(chalk.green('状态: ') + chalk.white('新文件')); - console.log(chalk.green(`+${changes.additions} 行`)); - } else { - console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`)); - } - - console.log(''); - console.log(chalk.gray('─'.repeat(60))); - - // 限制显示行数 - const diffOutput = formatDiff(diff, filePath); - const lines = diffOutput.split('\n'); - const MAX_LINES = 50; - - if (lines.length > MAX_LINES) { - console.log(lines.slice(0, MAX_LINES).join('\n')); - console.log(chalk.yellow(`\n... 省略 ${lines.length - MAX_LINES} 行 ...`)); - } else { - console.log(diffOutput); - } - - console.log(chalk.gray('─'.repeat(60))); - console.log(''); - - // 询问用户确认 - return promptConfirm(); -} - -/** - * 显示文件编辑的 diff 并请求确认 - */ -export async function promptFileEdit(ctx: FilePermissionContext): Promise { - const { path: filePath, oldContent, newContent } = ctx; - - if (!oldContent || !newContent) { - // 没有内容,使用简单确认 - return promptSimpleConfirm(ctx); - } - - // 显示编辑 diff - console.log(''); - console.log(chalk.yellow('✏️ 文件编辑预览')); - console.log(chalk.cyan('文件: ') + chalk.white(filePath)); - console.log(''); - console.log(chalk.gray('─'.repeat(60))); - console.log(formatEditDiff(oldContent, newContent)); - console.log(chalk.gray('─'.repeat(60))); - console.log(''); - - // 询问用户确认 - return promptConfirm(); -} - -/** - * 简单确认(无 diff) - */ -async function promptSimpleConfirm(ctx: FilePermissionContext): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - console.log(''); - console.log(chalk.yellow('⚠️ 文件操作确认')); - console.log(chalk.cyan('操作: ') + chalk.white(ctx.operation)); - console.log(chalk.cyan('文件: ') + chalk.white(ctx.path)); - console.log(''); - - showConfirmOptions(); - - rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { - rl.close(); - resolve(parseAnswer(answer)); - }); - }); -} - -/** - * 通用确认提示 - */ -async function promptConfirm(): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - showConfirmOptions(); - - rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { - rl.close(); - resolve(parseAnswer(answer)); - }); - }); -} - -/** - * 显示确认选项 - */ -function showConfirmOptions(): void { - console.log(chalk.white('选择操作:')); - console.log(chalk.green(' [y] ') + '确认执行'); - console.log(chalk.green(' [Y] ') + '确认执行,并记住此类操作(本次会话)'); - console.log(chalk.red(' [n] ') + '拒绝执行'); - console.log(chalk.red(' [N] ') + '拒绝执行,并记住此类操作(本次会话)'); - console.log(''); -} - -/** - * 解析用户输入 - */ -function parseAnswer(answer: string): PermissionDecision { - const choice = answer.trim(); - - switch (choice) { - case 'y': - return { allow: true, remember: false }; - case 'Y': - return { allow: true, remember: true }; - case 'N': - return { allow: false, remember: true }; - case 'n': - default: - return { allow: false, remember: false }; - } -} - -/** - * 根据操作类型选择合适的确认提示 - */ -export async function promptFilePermission(ctx: FilePermissionContext): Promise { - switch (ctx.operation) { - case 'write': - return promptFileWrite(ctx); - case 'edit': - return promptFileEdit(ctx); - default: - return promptSimpleConfirm(ctx); - } -} diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts index 510c913..b4cff31 100644 --- a/packages/core/src/permission/index.ts +++ b/packages/core/src/permission/index.ts @@ -8,16 +8,17 @@ export type { FileOperation, FilePermissionContext, FilePermissionConfig, + WebPermissionContext, + WebPermissionConfig, + GitOperation, + GitPermissionContext, + GitPermissionConfig, } from './types.js'; export { matchPattern, matchRules, parseCommand, generateAskPattern } from './wildcard.js'; export { PermissionManager, getPermissionManager, resetPermissionManager } from './manager.js'; -export { promptPermission, showPermissionDenied, showPermissionAllowed } from './prompt.js'; - -export { promptFilePermission, promptFileWrite, promptFileEdit } from './file-prompt.js'; - // Checker pattern exports export type { PermissionChecker, BasePermissionConfig } from './checkers/base.js'; export { BashPermissionChecker } from './checkers/bash.js'; diff --git a/packages/core/src/permission/prompt.ts b/packages/core/src/permission/prompt.ts deleted file mode 100644 index 38ce81e..0000000 --- a/packages/core/src/permission/prompt.ts +++ /dev/null @@ -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 { - 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}`)); -} diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index 4baed6a..6af99f3 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -223,164 +223,3 @@ export function saveConfig(config: Partial): void { fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2)); } -// 初始化配置向导 -export async function initConfig(): Promise { - 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 = { - 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 = { - 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'); -} diff --git a/packages/core/src/utils/diff.ts b/packages/core/src/utils/diff.ts index ea33345..5d04ffc 100644 --- a/packages/core/src/utils/diff.ts +++ b/packages/core/src/utils/diff.ts @@ -1,12 +1,8 @@ /** - * 文件 diff 对比和确认工具 - * 用于在写入文件前显示变更并让用户确认 + * 文件 diff 计算工具 + * 纯计算逻辑,不包含任何 UI 渲染 */ -import * as readline from 'readline'; -import * as fs from 'fs/promises'; -import chalk from 'chalk'; - export interface DiffLine { type: 'add' | 'remove' | 'context'; lineNumber: number | null; @@ -225,47 +221,6 @@ function computeLCS(oldLines: string[], newLines: string[]): Array<{ content: st return result; } -/** - * 格式化 diff 输出 - */ -export function formatDiff(diff: DiffResult, filePath: string): string { - const lines: string[] = []; - - if (diff.isNew) { - lines.push(chalk.green(`+++ 新文件: ${filePath}`)); - } else { - lines.push(chalk.gray(`--- ${filePath} (原文件)`)); - lines.push(chalk.green(`+++ ${filePath} (修改后)`)); - } - - lines.push(''); - - for (const hunk of diff.hunks) { - // Hunk 头部 - lines.push(chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`)); - - for (const line of hunk.lines) { - const lineNum = line.lineNumber ? chalk.gray(`${line.lineNumber.toString().padStart(4)} `) : ' '; - - switch (line.type) { - case 'add': - lines.push(chalk.green(`${lineNum}+ ${line.content}`)); - break; - case 'remove': - lines.push(chalk.red(`${lineNum}- ${line.content}`)); - break; - case 'context': - lines.push(chalk.gray(`${lineNum} ${line.content}`)); - break; - } - } - - lines.push(''); - } - - return lines.join('\n'); -} - /** * 统计变更数量 */ @@ -282,113 +237,3 @@ export function countChanges(diff: DiffResult): { additions: number; deletions: return { additions, deletions }; } - -export interface FileConfirmResult { - confirmed: boolean; - remember: boolean; -} - -/** - * 显示 diff 并让用户确认 - */ -export async function confirmFileChange( - filePath: string, - newContent: string, - operation: 'write' | 'edit' -): Promise { - // 读取原文件内容 - let oldContent: string | null = null; - try { - oldContent = await fs.readFile(filePath, 'utf-8'); - } catch { - // 文件不存在,是新文件 - } - - // 如果内容相同,直接通过 - if (oldContent === newContent) { - return { confirmed: true, remember: false }; - } - - // 计算 diff - const diff = computeDiff(oldContent, newContent); - const changes = countChanges(diff); - - // 显示 diff - console.log(''); - console.log(chalk.yellow('📝 文件变更预览')); - console.log(chalk.cyan('操作: ') + chalk.white(operation === 'write' ? '写入文件' : '编辑文件')); - console.log(chalk.cyan('文件: ') + chalk.white(filePath)); - console.log(chalk.green(`+${changes.additions} 行`) + ' / ' + chalk.red(`-${changes.deletions} 行`)); - console.log(''); - console.log(chalk.gray('─'.repeat(60))); - console.log(formatDiff(diff, filePath)); - console.log(chalk.gray('─'.repeat(60))); - console.log(''); - - // 询问用户确认 - return promptFileConfirm(); -} - -/** - * 提示用户确认文件操作 - */ -async function promptFileConfirm(): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - console.log(chalk.white('选择操作:')); - console.log(chalk.green(' [y] ') + '确认写入'); - console.log(chalk.green(' [Y] ') + '确认写入,并记住此类操作(本次会话)'); - console.log(chalk.red(' [n] ') + '取消操作'); - console.log(chalk.red(' [N] ') + '取消操作,并记住此类操作(本次会话)'); - console.log(''); - - rl.question(chalk.yellow('请选择 [y/Y/n/N]: '), (answer) => { - rl.close(); - - const choice = answer.trim(); - - switch (choice) { - case 'y': - resolve({ confirmed: true, remember: false }); - break; - case 'Y': - resolve({ confirmed: true, remember: true }); - break; - case 'N': - resolve({ confirmed: false, remember: true }); - break; - case 'n': - default: - resolve({ confirmed: false, remember: false }); - break; - } - }); - }); -} - -/** - * 简化的 diff 显示(用于编辑操作,只显示变更部分) - */ -export function formatEditDiff(oldString: string, newString: string): string { - const lines: string[] = []; - - lines.push(chalk.gray('变更内容:')); - - // 显示删除的内容 - const oldLines = oldString.split('\n'); - for (const line of oldLines) { - lines.push(chalk.red(`- ${line}`)); - } - - // 显示新增的内容 - const newLines = newString.split('\n'); - for (const line of newLines) { - lines.push(chalk.green(`+ ${line}`)); - } - - return lines.join('\n'); -} diff --git a/packages/core/tests/unit/permission/file-prompt.test.ts b/packages/core/tests/unit/permission/file-prompt.test.ts deleted file mode 100644 index 7232613..0000000 --- a/packages/core/tests/unit/permission/file-prompt.test.ts +++ /dev/null @@ -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; - - 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 }); - }); - }); -}); diff --git a/packages/core/tests/unit/permission/prompt.test.ts b/packages/core/tests/unit/permission/prompt.test.ts deleted file mode 100644 index e29eaa4..0000000 --- a/packages/core/tests/unit/permission/prompt.test.ts +++ /dev/null @@ -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; - - 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('项目目录外的路径'); - }); - }); -}); diff --git a/packages/core/tests/unit/utils/diff-extended.test.ts b/packages/core/tests/unit/utils/diff-extended.test.ts index 1764b59..b5d1f7c 100644 --- a/packages/core/tests/unit/utils/diff-extended.test.ts +++ b/packages/core/tests/unit/utils/diff-extended.test.ts @@ -1,39 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - 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'; +import { describe, it, expect } from 'vitest'; +import { computeDiff, countChanges } from '../../../src/utils/diff.js'; describe('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 - 统计变更', () => { it('统计添加行数', () => { const diff = computeDiff(null, 'line1\nline2\nline3'); @@ -191,173 +118,4 @@ describe('Diff - 差异比较扩展测试', () => { 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; - - 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(); - }); - }); }); diff --git a/packages/core/tests/unit/utils/diff.test.ts b/packages/core/tests/unit/utils/diff.test.ts index c02a9d7..abd17e9 100644 --- a/packages/core/tests/unit/utils/diff.test.ts +++ b/packages/core/tests/unit/utils/diff.test.ts @@ -1,5 +1,5 @@ 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('新文件', () => { @@ -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 结构', () => { 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('实际代码场景', () => { it('函数修改', () => { diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index e681539..7ba1d05 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -11,6 +11,7 @@ import type { SessionStatus } from '../types.js'; import { getSessionManager } from '../session/manager.js'; import { broadcastToSession } from '../ws.js'; import { emitStatusEvent, emitLogEvent } from '../sse.js'; +import { createServerPermissionCallback } from '../permission/handler.js'; // ============================================================================ // Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型) @@ -41,6 +42,13 @@ interface ToolRegistry { getAllTools(): unknown[]; } +/** + * Permission Manager 接口 + */ +interface PermissionManager { + setAskCallback(callback: (ctx: unknown) => Promise<{ allow: boolean; remember?: boolean }>): void; +} + /** * Core 模块接口 */ @@ -48,6 +56,7 @@ interface CoreModule { Agent: AgentConstructor; toolRegistry: ToolRegistry; loadConfig: () => unknown; + getPermissionManager: (projectRoot?: string) => PermissionManager; } // ============================================================================ @@ -114,6 +123,10 @@ export function getOrCreateAgent(sessionId: string): AgentInstance | null { const agent = new coreModule.Agent(config); agent.setRegistry(coreModule.toolRegistry); + // 设置权限回调,通过 WebSocket 请求用户确认 + const permissionManager = coreModule.getPermissionManager(); + permissionManager.setAskCallback(createServerPermissionCallback(sessionId)); + agentCache.set(sessionId, agent); return agent; } diff --git a/packages/server/src/permission/handler.ts b/packages/server/src/permission/handler.ts new file mode 100644 index 0000000..4c81bb6 --- /dev/null +++ b/packages/server/src/permission/handler.ts @@ -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; +} + +const pendingRequests = new Map(); + +// 默认超时时间(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 => { + 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; +} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index d58a9d4..25ef68e 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -89,12 +89,16 @@ export type Tool = z.infer; // 客户端发送的消息 export interface ClientMessage { - type: 'message' | 'cancel' | 'tool_response'; + type: 'message' | 'cancel' | 'tool_response' | 'permission_response'; sessionId: string; payload?: { content?: string; toolCallId?: string; approved?: boolean; + // Permission response fields + requestId?: string; + allow?: boolean; + remember?: boolean; }; } @@ -109,11 +113,70 @@ export interface ServerMessage { | 'done' | 'cancelled' | 'error' - | 'session_updated'; + | 'session_updated' + | 'permission_request'; sessionId: string; 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 事件 ============ export interface SSEEvent { diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index 6ebbc73..f3af87e 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -7,6 +7,7 @@ import type { WSContext } from 'hono/ws'; import { getSessionManager } from './session/manager.js'; import { processMessage, cancelProcessing } from './agent/index.js'; +import { handlePermissionResponse } from './permission/handler.js'; import type { ClientMessage, ServerMessage } from './types.js'; // 存储活跃的 WebSocket 连接 @@ -143,6 +144,18 @@ export async function handleWebSocketMessage( 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: ws.send( JSON.stringify({ diff --git a/packages/ui/src/components/PermissionDialog.tsx b/packages/ui/src/components/PermissionDialog.tsx new file mode 100644 index 0000000..1283693 --- /dev/null +++ b/packages/ui/src/components/PermissionDialog.tsx @@ -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 ; + case 'file': + return ; + case 'git': + return ; + case 'web': + return ; + default: + return ; + } +} + +// 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 ( +
+
+ + {diff.isNew ? 'New file' : 'Changes'} + +
+ +{diff.additions} + -{diff.deletions} +
+
+
+
+          {diff.hunks.map((hunk, hunkIndex) => (
+            
+
+ @@ -{hunk.oldStart},{hunk.oldCount} +{hunk.newStart},{hunk.newCount} @@ +
+ {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 ( +
+ {prefix} + {line.content} +
+ ); + })} +
+ ))} +
+
+
+ ); +} + +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 ( +
+
Command:
+ + {context.command} + + {context.externalPaths && context.externalPaths.length > 0 && ( +
+ +
+
External paths detected:
+
+ {context.externalPaths.map((p, i) => ( +
{p}
+ ))} +
+
+
+ )} +
+ ); + + case 'file': + return ( +
+
+ Operation: + + {context.operation?.toUpperCase()} + +
+
Path:
+ + {context.path} + + {diff && } +
+ ); + + case 'git': + return ( +
+
+ Git operation: + + {context.gitOperation?.toUpperCase()} + +
+ {context.command && ( + <> +
Command:
+ + {context.command} + + + )} +
+ ); + + case 'web': + return ( +
+
Request:
+ + {context.query || context.command} + +
+ ); + + default: + return ( +
+
+              {JSON.stringify(context, null, 2)}
+            
+
+ ); + } + }; + + return ( + + + + {/* Header */} +
+ {responsive && ( +
+ )} +
+
+ {getPermissionIcon(permissionType)} +
+
+

{getPermissionTitle(permissionType)}

+

AI is requesting permission

+
+
+ +
+ + {/* Content */} +
+ {renderContext()} +
+ + {/* Footer */} +
+ {/* Remember checkbox */} + + + {/* Action buttons */} +
+ + +
+
+ + + + ); +} diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 19a3944..a861fb6 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -6,6 +6,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { createWebSocket, getMessages, type Message } from '../api/client.js'; +import type { PermissionRequest } from '../components/PermissionDialog.js'; interface UseChatOptions { sessionId: string; @@ -19,6 +20,7 @@ interface ChatState { isConnected: boolean; isLoading: boolean; streamingContent: string; + permissionRequest: PermissionRequest | null; } export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated }: UseChatOptions) { @@ -27,10 +29,11 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate isConnected: false, isLoading: false, streamingContent: '', + permissionRequest: null, }); const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(); + const reconnectTimeoutRef = useRef>(); const reconnectAttemptsRef = useRef(0); const maxReconnectAttempts = 5; // 标记是否正在主动关闭连接(切换 session 时) @@ -144,6 +147,16 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate onSessionUpdatedRef.current?.(message.payload.id, message.payload.name); } break; + + case 'permission_request': + // 权限请求 + if (message.payload) { + setState((prev) => ({ + ...prev, + permissionRequest: message.payload as PermissionRequest, + })); + } + break; } } catch { // 忽略解析错误 @@ -188,6 +201,44 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' })); }, [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(() => { // 重置状态 @@ -197,6 +248,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate isConnected: false, isLoading: false, streamingContent: '', + permissionRequest: null, }); reconnectAttemptsRef.current = 0; @@ -225,5 +277,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate sendMessage, cancelProcessing, reload: loadMessages, + allowPermission, + denyPermission, }; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0ac3933..747dc32 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -165,6 +165,8 @@ export { AgentDefaultsEditor } from './components/AgentDefaultsEditor.js'; export { CheckpointPanel } from './components/CheckpointPanel.js'; export { CheckpointDiffViewer } from './components/CheckpointDiffViewer.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 { FileBrowser } from './components/FileBrowser.js'; export { ConfigPanel } from './components/ConfigPanel.js'; diff --git a/packages/web/src/pages/Chat.tsx b/packages/web/src/pages/Chat.tsx index 12ff646..85bcd70 100644 --- a/packages/web/src/pages/Chat.tsx +++ b/packages/web/src/pages/Chat.tsx @@ -11,6 +11,7 @@ import { StreamingMessage, TypingIndicator, ChatInput, + PermissionDialog, } from '@ai-assistant/ui'; interface ChatPageProps { @@ -50,6 +51,9 @@ export function ChatPage({ streamingContent, sendMessage, cancelProcessing, + permissionRequest, + allowPermission, + denyPermission, } = useChat({ sessionId, onError: (error) => { @@ -264,6 +268,16 @@ export function ChatPage({ disabled={!isConnected} responsive={responsive} /> + + {/* Permission Dialog */} + {permissionRequest && ( + allowPermission(requestId, remember)} + onDeny={(requestId, remember) => denyPermission(requestId, remember)} + responsive={responsive} + /> + )}
); }