refactor: 命令执行走工作空间终端,AI 不再直接执行

流程改为:AI 提出命令 → 用户确认 → 工作空间终端执行(实时显示)→ 结果反馈 AI → AI 回复

- handleRunCommand 直接 spawn 子进程,stdout/stderr 实时通过 cmd:output 推送
- 进程结束后通过 cmd:done 通知渲染进程
- 停止按钮通过 cmd:kill 终止进程
- 清理 workspace.ts 中不再需要的 execWorkspaceCommand 工具集成代码
- 去掉 workspace:execTool/killTool,改为 cmd:output/cmd:done/cmd:kill
This commit is contained in:
thzxx
2026-04-08 13:12:18 +08:00
parent d5e5f5f58a
commit 0431ed56c0
7 changed files with 107 additions and 84 deletions
+6 -19
View File
@@ -19,10 +19,11 @@ import {
handleSearchFiles, handleSearchFiles,
handleCreateDir, handleCreateDir,
handleDeleteFile, handleDeleteFile,
handleRunCommand handleRunCommand,
killToolProcess
} from './tool-handlers.js'; } from './tool-handlers.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js'; import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand, terminateCurrentToolCommand } from './workspace.js'; import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
export function setupIPC(): void { export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => { ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
@@ -172,24 +173,10 @@ export function setupIPC(): void {
killProcess(id); killProcess(id);
}); });
// 无超时工具命令执行(AI Tool Calling 集成)
ipcMain.handle('workspace:execTool', async (event, command: string, cwd?: string) => {
sendLog('info', `🔧 workspace:execTool`, `${command.slice(0, 200)} | cwd: ${cwd || '默认'}`);
const result = await execWorkspaceCommand(command, cwd, (type, data) => {
// 实时推送到工作空间终端
event.sender.send('workspace:toolOutput', { command, type, data });
});
// 通知命令执行完毕
event.sender.send('workspace:toolDone', { command, exitCode: result.exitCode });
return result;
});
// 终止当前 AI 工具命令 // 终止当前 AI 工具命令
ipcMain.handle('workspace:killTool', async () => { ipcMain.handle('cmd:kill', async () => {
const killed = terminateCurrentToolCommand(); const killed = killToolProcess();
sendLog('info', `🔧 workspace:killTool`, killed ? '已终止' : '无正在运行的工具命令'); sendLog('info', `🔧 cmd:kill`, killed ? '已终止' : '无正在运行的工具命令');
return { killed }; return { killed };
}); });
} }
+11 -11
View File
@@ -59,17 +59,17 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
onExit: (callback: (data: { id: string; code: number | null }) => void) => { onExit: (callback: (data: { id: string; code: number | null }) => void) => {
ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data)); ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data));
}, },
/** 无超时命令执行,用于 AI Tool Calling */ /** 无超时工具命令执行,用于 AI Tool Calling */
execTool: (command: string, cwd?: string) => ipcRenderer.invoke('workspace:execTool', command, cwd), execTool: (command: string, cwd?: string) => ipcRenderer.invoke('tool:execute', 'run_command', { command, cwd }),
/** 终止当前 AI 工具命令 */ /** AI 命令实时输出推送到工作空间终端 */
killTool: () => ipcRenderer.invoke('workspace:killTool'), onCmdOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => {
/** AI Tool Calling 实时输出推送到工作空间终端 */ ipcRenderer.on('cmd:output', (_: unknown, data: { command: string; type: 'stdout' | 'stderr'; data: string }) => callback(data));
onToolOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => {
ipcRenderer.on('workspace:toolOutput', (_: unknown, data: { command: string; type: 'stdout' | 'stderr'; data: string }) => callback(data));
}, },
/** AI Tool Calling 命令执行完毕 */ /** AI 命令执行完毕 */
onToolDone: (callback: (data: { command: string; exitCode: number | null }) => void) => { onCmdDone: (callback: (data: { command: string; exitCode: number | null }) => void) => {
ipcRenderer.on('workspace:toolDone', (_: unknown, data: { command: string; exitCode: number | null }) => callback(data)); ipcRenderer.on('cmd:done', (_: unknown, data: { command: string; exitCode: number | null }) => callback(data));
} },
/** 终止当前 AI 命令 */
cmdKill: () => ipcRenderer.invoke('cmd:kill')
} }
}); });
+73 -18
View File
@@ -5,8 +5,10 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { spawn } from 'child_process';
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
import { execWorkspaceCommand } from './workspace.js'; import { mainWindow } from './main.js';
import { getWorkspaceDir } from './workspace.js';
export interface ToolResult { export interface ToolResult {
success: boolean; success: boolean;
@@ -262,29 +264,82 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
} }
} }
/** 当前工具命令进程(用于用户手动终止) */
let _toolProc: ReturnType<typeof spawn> | null = null;
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> { export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
try { try {
const cmdCheck = checkCommandAllowed(params.command); const cmdCheck = checkCommandAllowed(params.command);
if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason }; if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason };
// 通过 workspace 子进程执行,复用安全检查和进程管理 const cwd = params.cwd ? path.resolve(params.cwd) : getWorkspaceDir();
const result = await execWorkspaceCommand(params.command, params.cwd); const dirCheck = checkPathAllowed(cwd, 'read');
if (!dirCheck.ok) return { success: false, error: dirCheck.reason };
return { return new Promise((resolve) => {
success: result.success, const isWin = process.platform === 'win32';
stdout: result.stdout, const shell = isWin ? 'cmd.exe' : 'bash';
stderr: result.stderr, const shellArgs = isWin ? ['/c', params.command] : ['-c', params.command];
exitCode: result.exitCode, const startTime = Date.now();
duration: result.duration
}; _toolProc = spawn(shell, shellArgs, {
cwd,
env: { ...process.env, ...(isWin ? {} : { TERM: 'xterm-256color' }) },
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
// 实时推送到工作空间终端
_toolProc.stdout?.on('data', (data: Buffer) => {
const str = data.toString();
stdout += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
});
_toolProc.stderr?.on('data', (data: Buffer) => {
const str = data.toString();
stderr += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
});
_toolProc.on('close', (code) => {
_toolProc = null;
const duration = Date.now() - startTime;
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code });
resolve({
success: code === 0,
stdout,
stderr,
exitCode: code,
duration
});
});
_toolProc.on('error', (err) => {
_toolProc = null;
const duration = Date.now() - startTime;
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: 1 });
resolve({
success: false,
stdout,
stderr: stderr + (stderr ? '\n' : '') + err.message,
exitCode: 1,
error: err.message,
duration
});
});
});
} catch (err) { } catch (err) {
const error = err as { message: string }; return { success: false, stdout: '', stderr: (err as Error).message, exitCode: 1, error: (err as Error).message };
return {
success: false,
stdout: '',
stderr: error.message,
exitCode: 1,
error: error.message
};
} }
} }
/** 终止当前工具命令进程 */
export function killToolProcess(): boolean {
if (!_toolProc) return false;
try { _toolProc.kill('SIGTERM'); } catch { /* ignore */ }
_toolProc = null;
return true;
}
-17
View File
@@ -20,9 +20,6 @@ export const DEFAULT_WORKSPACE_DIR = path.join(app.getPath('userData'), 'workspa
/** 活跃进程 Map */ /** 活跃进程 Map */
const activeProcesses = new Map<string, ChildProcess>(); const activeProcesses = new Map<string, ChildProcess>();
/** 当前 AI 工具命令进程 ID(用于用户手动终止) */
let _currentToolProcessId: string | null = null;
/** 确保工作空间目录存在 */ /** 确保工作空间目录存在 */
export function ensureWorkspaceDir(): void { export function ensureWorkspaceDir(): void {
if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) { if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) {
@@ -257,7 +254,6 @@ export function execWorkspaceCommand(
}); });
activeProcesses.set(cmdId, proc); activeProcesses.set(cmdId, proc);
_currentToolProcessId = cmdId;
proc.stdout.on('data', (data: Buffer) => { proc.stdout.on('data', (data: Buffer) => {
const str = data.toString(); const str = data.toString();
@@ -291,7 +287,6 @@ export function execWorkspaceCommand(
proc.on('close', (code) => { proc.on('close', (code) => {
activeProcesses.delete(cmdId); activeProcesses.delete(cmdId);
if (_currentToolProcessId === cmdId) _currentToolProcessId = null;
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
sendLog( sendLog(
code === 0 ? 'success' : 'warn', code === 0 ? 'success' : 'warn',
@@ -311,7 +306,6 @@ export function execWorkspaceCommand(
proc.on('error', (err) => { proc.on('error', (err) => {
activeProcesses.delete(cmdId); activeProcesses.delete(cmdId);
if (_currentToolProcessId === cmdId) _currentToolProcessId = null;
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
sendLog('error', `🔧 execWorkspaceCommand 失败`, `${cmdId} | ${err.message}`); sendLog('error', `🔧 execWorkspaceCommand 失败`, `${cmdId} | ${err.message}`);
resolve({ resolve({
@@ -347,14 +341,3 @@ export function execWorkspaceCommand(
export function terminateWorkspaceCommand(commandId: string): boolean { export function terminateWorkspaceCommand(commandId: string): boolean {
return killProcess(commandId); return killProcess(commandId);
} }
/**
* 终止当前正在运行的 AI 工具命令
* @returns 是否成功终止
*/
export function terminateCurrentToolCommand(): boolean {
if (!_currentToolProcessId) return false;
const id = _currentToolProcessId;
_currentToolProcessId = null;
return killProcess(id);
}
+7 -7
View File
@@ -182,14 +182,14 @@ export async function initWorkspacePanel(): Promise<void> {
handleProcessExit(data.id, data.code); handleProcessExit(data.id, data.code);
}); });
// 监听 AI Tool Calling 实时输出,流式推送到终端面板 // 监听 AI 命令实时输出,流式推送到终端面板
bridge.workspace.onToolOutput((data) => { bridge.workspace.onCmdOutput((data) => {
appendToolStreamOutput(data.command, data.type, data.data); appendToolStreamOutput(data.command, data.type, data.data);
}); });
// 监听 AI Tool Calling 命令执行完毕 // 监听 AI 命令执行完毕
bridge.workspace.onToolDone((data) => { bridge.workspace.onCmdDone((data) => {
logDebug('工作空间工具命令完成', `command: ${data.command.slice(0, 50)} | exitCode: ${data.exitCode}`); logDebug('工作空间 AI 命令完成', `command: ${data.command.slice(0, 50)} | exitCode: ${data.exitCode}`);
handleToolDone(data.command, data.exitCode); handleToolDone(data.command, data.exitCode);
}); });
@@ -383,8 +383,8 @@ function killCurrentProcess(): void {
bridge.workspace.kill(session.id); bridge.workspace.kill(session.id);
} }
// 终止当前 AI 工具命令 // 终止当前 AI 命令
bridge.workspace.killTool(); bridge.workspace.cmdKill();
const cmd = currentAiCommand; const cmd = currentAiCommand;
session.lines.push({ type: 'system', text: '■ 命令已手动终止' }); session.lines.push({ type: 'system', text: '■ 命令已手动终止' });
+3 -5
View File
@@ -161,14 +161,14 @@ export async function executeTool(toolName: string, args: Record<string, unknown
try { try {
logToolStart(toolName, JSON.stringify(args)); logToolStart(toolName, JSON.stringify(args));
// run_command 走 workspace 无超时 IPC,其他工具走标准 IPC // run_command 走 workspace IPC
if (toolName === 'run_command') { if (toolName === 'run_command') {
const command = args.command as string; const command = args.command as string;
const cwd = args.cwd as string | undefined; const cwd = args.cwd as string | undefined;
if (!command) { if (!command) {
return { success: false, error: '缺少 command 参数' }; return { success: false, error: '缺少 command 参数' };
} }
logInfo(`Workspace 命令执行: ${command.slice(0, 100)}`); logInfo(`工作空间命令执行: ${command.slice(0, 100)}`);
const result = await bridge.workspace.execTool(command, cwd); const result = await bridge.workspace.execTool(command, cwd);
logToolResult('run_command', result.success, result.success ? undefined : result.stderr?.slice(0, 200)); logToolResult('run_command', result.success, result.success ? undefined : result.stderr?.slice(0, 200));
return { return {
@@ -176,9 +176,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
stdout: result.stdout, stdout: result.stdout,
stderr: result.stderr, stderr: result.stderr,
exitCode: result.exitCode, exitCode: result.exitCode,
duration: result.duration, duration: result.duration
userTerminated: result.userTerminated,
truncated: result.truncated
}; };
} }
+7 -7
View File
@@ -240,13 +240,13 @@ export interface MetonaDesktopAPI {
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void; onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
onExit: (callback: (data: { id: string; code: number | null }) => void) => void; onExit: (callback: (data: { id: string; code: number | null }) => void) => void;
/** 无超时工具命令执行,用于 AI Tool Calling 集成 */ /** 无超时工具命令执行,用于 AI Tool Calling 集成 */
execTool: (command: string, cwd?: string) => Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number; userTerminated: boolean; truncated: boolean }>; execTool: (command: string, cwd?: string) => Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number }>;
/** AI Tool Calling 实时输出推送到工作空间终端 */ /** AI 命令实时输出推送到工作空间终端 */
onToolOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => void; onCmdOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
/** AI Tool Calling 命令执行完毕 */ /** AI 命令执行完毕 */
onToolDone: (callback: (data: { command: string; exitCode: number | null }) => void) => void; onCmdDone: (callback: (data: { command: string; exitCode: number | null }) => void) => void;
/** 终止当前 AI 工具命令 */ /** 终止当前 AI 命令 */
killTool: () => Promise<{ killed: boolean }>; cmdKill: () => Promise<{ killed: boolean }>;
}; };
} }