refactor: 工作空间去掉手动命令输入,终止时自动反馈 AI

- 移除工作空间终端的命令输入框和执行按钮
- 用户只能通过终止按钮中断卡住的 AI 命令
- 终止后自动通过 killTool IPC 终止进程,结果自动回传 AI
- 新增 workspace:killTool IPC 和 terminateCurrentToolCommand
- 新增终端底部状态提示(等待/执行中)
- 修复工作空间面板顶部对齐(90px → 92px)
This commit is contained in:
thzxx
2026-04-08 12:57:48 +08:00
parent d5f4112d97
commit d150b53116
7 changed files with 130 additions and 110 deletions
+8 -1
View File
@@ -22,7 +22,7 @@ import {
handleRunCommand
} from './tool-handlers.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand } from './workspace.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand, terminateCurrentToolCommand } from './workspace.js';
export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
@@ -177,4 +177,11 @@ export function setupIPC(): void {
sendLog('info', `🔧 workspace:execTool`, `${command.slice(0, 200)} | cwd: ${cwd || '默认'}`);
return execWorkspaceCommand(command, cwd);
});
// 终止当前 AI 工具命令
ipcMain.handle('workspace:killTool', async () => {
const killed = terminateCurrentToolCommand();
sendLog('info', `🔧 workspace:killTool`, killed ? '已终止' : '无正在运行的工具命令');
return { killed };
});
}
+2
View File
@@ -61,6 +61,8 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
},
/** 无超时命令执行,用于 AI Tool Calling */
execTool: (command: string, cwd?: string) => ipcRenderer.invoke('workspace:execTool', command, cwd),
/** 终止当前 AI 工具命令 */
killTool: () => ipcRenderer.invoke('workspace:killTool'),
/** AI Tool Calling 命令输出 → 同步到工作空间终端 */
onToolOutput: (callback: (data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => void) => {
ipcRenderer.on('workspace:toolOutput', (_: unknown, data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => callback(data));
+17
View File
@@ -20,6 +20,9 @@ export const DEFAULT_WORKSPACE_DIR = path.join(app.getPath('userData'), 'workspa
/** 活跃进程 Map */
const activeProcesses = new Map<string, ChildProcess>();
/** 当前 AI 工具命令进程 ID(用于用户手动终止) */
let _currentToolProcessId: string | null = null;
/** 确保工作空间目录存在 */
export function ensureWorkspaceDir(): void {
if (!fs.existsSync(DEFAULT_WORKSPACE_DIR)) {
@@ -253,6 +256,7 @@ export function execWorkspaceCommand(
});
activeProcesses.set(cmdId, proc);
_currentToolProcessId = cmdId;
proc.stdout.on('data', (data: Buffer) => {
if (stdoutBuf.length < MAX_OUTPUT) {
@@ -280,6 +284,7 @@ export function execWorkspaceCommand(
proc.on('close', (code) => {
activeProcesses.delete(cmdId);
if (_currentToolProcessId === cmdId) _currentToolProcessId = null;
const duration = Date.now() - startTime;
sendLog(
code === 0 ? 'success' : 'warn',
@@ -299,6 +304,7 @@ export function execWorkspaceCommand(
proc.on('error', (err) => {
activeProcesses.delete(cmdId);
if (_currentToolProcessId === cmdId) _currentToolProcessId = null;
const duration = Date.now() - startTime;
sendLog('error', `🔧 execWorkspaceCommand 失败`, `${cmdId} | ${err.message}`);
resolve({
@@ -334,3 +340,14 @@ export function execWorkspaceCommand(
export function terminateWorkspaceCommand(commandId: string): boolean {
return killProcess(commandId);
}
/**
* 终止当前正在运行的 AI 工具命令
* @returns 是否成功终止
*/
export function terminateCurrentToolCommand(): boolean {
if (!_currentToolProcessId) return false;
const id = _currentToolProcessId;
_currentToolProcessId = null;
return killProcess(id);
}
+56 -69
View File
@@ -39,6 +39,17 @@ let fileTree: FileNode[] = [];
let previewFile: { name: string; content: string; path: string } | null = null;
let _counter = 0;
/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */
let currentAiCommand: string | null = null;
/** 终止通知回调(由外部设置) */
let onToolTerminated: ((command: string) => void) | null = null;
/** 设置终止通知回调 */
export function setOnToolTerminated(cb: (command: string) => void): void {
onToolTerminated = cb;
}
function genId(): string {
return `ws_${Date.now()}_${++_counter}`;
}
@@ -191,28 +202,6 @@ function bindEvents(): void {
document.querySelector('#wsTabTerminal')?.addEventListener('click', () => switchTab('terminal'));
document.querySelector('#wsTabFiles')?.addEventListener('click', () => switchTab('files'));
// 终端输入
const termInput = document.querySelector('#wsTermInput') as HTMLInputElement;
termInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const cmd = termInput.value.trim();
if (cmd) {
executeCommand(cmd);
termInput.value = '';
}
}
});
// 执行按钮
document.querySelector('#wsExecBtn')?.addEventListener('click', () => {
const cmd = termInput?.value.trim();
if (cmd) {
executeCommand(cmd);
termInput.value = '';
}
});
// 停止按钮
document.querySelector('#wsStopBtn')?.addEventListener('click', killCurrentProcess);
@@ -274,36 +263,16 @@ function getActiveSession(): TerminalSession | undefined {
return terminalSessions.find(s => s.id === activeTerminalId);
}
function executeCommand(command: string): void {
const session = getActiveSession();
if (!session) {
logError('工作空间执行失败', '无活跃终端会话');
return;
}
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
logError('工作空间执行失败', '非桌面环境');
return;
}
// 显示命令
session.lines.push({ type: 'system', text: `$ ${command}` });
session.running = true;
session.autoScroll = true;
renderTerminal();
const cwd = currentFileDir || workspaceDir;
logInfo('工作空间执行命令', `ID: ${session.id} | ${command.slice(0, 100)} | cwd: ${cwd}`);
bridge.workspace.exec({ id: session.id, command, cwd });
}
/** AI Tool Calling 命令输出 → 追加到活跃终端 */
function appendToolOutput(command: string, stdout: string, stderr: string, exitCode: number | null): void {
const session = getActiveSession();
if (!session) return;
// 记录当前 AI 命令
currentAiCommand = command;
session.running = true;
updateHint();
// 显示执行的命令
session.lines.push({ type: 'system', text: `$ ${command} (AI)` });
@@ -325,12 +294,18 @@ function appendToolOutput(command: string, stdout: string, stderr: string, exitC
const exitMsg = exitCode === 0 ? '✓ AI 命令执行成功' : `✗ AI 命令退出 (code: ${exitCode})`;
session.lines.push({ type: 'system', text: exitMsg });
// 命令结束
session.running = false;
currentAiCommand = null;
// 限制最大行数
if (session.lines.length > 3000) {
session.lines = session.lines.slice(-2000);
}
renderTerminal();
updateStopBtnState();
updateHint();
}
function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void {
@@ -389,14 +364,32 @@ function handleProcessExit(sessionId: string, code: number | null): void {
function killCurrentProcess(): void {
const session = getActiveSession();
if (!session || !session.running) return;
if (!session) return;
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return;
bridge.workspace.kill(session.id);
session.lines.push({ type: 'system', text: '■ 已发送终止信号' });
// 终止用户终端进程(如果有)
if (session.running) {
bridge.workspace.kill(session.id);
}
// 终止当前 AI 工具命令
bridge.workspace.killTool();
const cmd = currentAiCommand;
session.lines.push({ type: 'system', text: '■ 命令已手动终止' });
session.running = false;
currentAiCommand = null;
renderTerminal();
updateStopBtnState();
updateHint();
// 通知 AI 命令被用户终止
if (cmd && onToolTerminated) {
onToolTerminated(cmd);
}
}
function clearTerminal(): void {
@@ -649,12 +642,23 @@ function renderPreview(): void {
function updateStopBtnState(): void {
const stopBtn = document.querySelector('#wsStopBtn') as HTMLButtonElement;
if (!stopBtn) return;
const session = getActiveSession();
const running = session?.running || false;
const running = currentAiCommand !== null;
stopBtn.disabled = !running;
stopBtn.classList.toggle('active', running);
}
function updateHint(): void {
const hint = document.querySelector('#wsTermHint') as HTMLElement;
if (!hint) return;
if (currentAiCommand) {
hint.textContent = `⏳ AI 正在执行: ${currentAiCommand.slice(0, 40)}${currentAiCommand.length > 40 ? '...' : ''}`;
hint.classList.add('running');
} else {
hint.textContent = '等待 AI 执行命令...';
hint.classList.remove('running');
}
}
// ── 工具函数 ──
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
@@ -676,23 +680,6 @@ function formatSize(bytes: number): string {
// ── 外部调用接口 ──
/** 从聊天区域调用:在工作空间执行命令 */
export function execInWorkspace(command: string): void {
// 切换到终端 tab
activeTab = 'terminal';
// 使用当前活跃终端或创建新的
const session = getActiveSession();
if (session?.running) {
// 当前终端有进程在跑,新建一个
const num = terminalSessions.length + 1;
createTerminalSession(`终端 ${num}`);
}
renderPanel();
executeCommand(command);
}
/** 获取工作空间目录 */
export function getWorkspaceDirPath(): string {
return workspaceDir;
+19 -22
View File
@@ -182,28 +182,25 @@
<div class="ws-term-tab-bar" id="wsTermTabBar"></div>
<div class="ws-term-output" id="wsTermOutput"></div>
<div class="ws-term-toolbar">
<input class="ws-term-input" id="wsTermInput" type="text" placeholder="输入命令,Enter 执行..." spellcheck="false">
<button class="ws-toolbar-btn" id="wsExecBtn" title="执行">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="ws-toolbar-btn ws-stop-btn" id="wsStopBtn" title="停止" disabled>
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsClearBtn" title="清空">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsNewTabBtn" title="新建终端">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<span class="ws-term-hint" id="wsTermHint">等待 AI 执行命令...</span>
<div class="ws-term-toolbar-btns">
<button class="ws-toolbar-btn ws-stop-btn" id="wsStopBtn" title="终止当前命令" disabled>
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsClearBtn" title="清空">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
<button class="ws-toolbar-btn" id="wsNewTabBtn" title="新建终端">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
</div>
</div>
+26 -18
View File
@@ -2667,7 +2667,7 @@ html, body {
.workspace-panel {
position: fixed;
top: 90px; /* header(44px) + model-bar(46px) */
top: 92px; /* header(44px) + border(1px) + model-bar(46px) + border(1px) */
right: 0;
bottom: 0;
width: 480px;
@@ -2838,31 +2838,39 @@ html, body {
.ws-term-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 6px 8px;
border-top: 1px solid var(--border-default, rgba(255,255,255,0.08));
background: var(--bg-layer-alt, #2a2a2a);
}
.ws-term-input {
.ws-term-hint {
flex: 1;
background: var(--bg-layer, #383838);
border: 1px solid var(--border-default, rgba(255,255,255,0.08));
border-radius: 4px;
color: var(--text-primary, #fff);
padding: 6px 10px;
font-size: 12px;
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
outline: none;
transition: border-color 0.15s;
}
.ws-term-input:focus {
border-color: var(--accent, #60CDFF);
}
.ws-term-input::placeholder {
font-size: 11px;
color: var(--text-tertiary, #666);
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 4px;
}
.ws-term-hint.running {
color: var(--accent, #60CDFF);
animation: hint-pulse 1.5s ease-in-out infinite;
}
@keyframes hint-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.ws-term-toolbar-btns {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.ws-toolbar-btn {
+2
View File
@@ -243,6 +243,8 @@ export interface MetonaDesktopAPI {
execTool: (command: string, cwd?: string) => Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number; userTerminated: boolean; truncated: boolean }>;
/** AI Tool Calling 命令输出 → 同步到工作空间终端 */
onToolOutput: (callback: (data: { command: string; stdout: string; stderr: string; exitCode: number | null }) => void) => void;
/** 终止当前 AI 工具命令 */
killTool: () => Promise<{ killed: boolean }>;
};
}