diff --git a/README.md b/README.md
index 9472f94..9c91f37 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
> 💻 桌面版仓库 | 🌐 [Web 版历史存档](https://gitee.com/thzxx/metona-ollama-web)
-
+



@@ -190,7 +190,7 @@ v3.2 四大子系统协调运作:
| 文件 | 类型 |
|------|------|
-| `Metona Ollama Setup 3.2.3.exe` | NSIS 安装包(可选目录、创建快捷方式) |
+| `Metona Ollama Setup 3.2.4.exe` | NSIS 安装包(可选目录、创建快捷方式) |
> ⚠️ v3.2.0 起不再提供便携版,仅保留 NSIS 安装包。
>
@@ -210,7 +210,7 @@ v3.2 四大子系统协调运作:
```bash
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
cd metona-ollama-desktop
-git checkout metona-ollama-desktop-v3.2.3
+git checkout metona-ollama-desktop-v3.2.4
# 安装依赖(国内使用 npmmirror 加速)
npm config set registry https://registry.npmmirror.com
@@ -385,52 +385,7 @@ metona-ollama-desktop/
## 📋 更新日志
-完整更新日志和项目发展历程请查看 [docs/CHANGELOG.md](docs/CHANGELOG.md)。
-
-### 最新版本
-
-**v3.2.3** — 工作空间面板常驻显示 & Bug 修复
-- 🖥️ 工作空间面板改为常驻显示(类似执行日志面板),固定宽度 480px,移除关闭按钮和拖拽手柄
-- 🔧 修复 `tool-registry.ts` 缺少 `logInfo` 导入导致 `run_command` 工具调用异常
-
-**v3.2.2** — 工作空间深度集成 & AI Tool Calling 增强
-- 🔧 工作空间与 AI Tool Calling 深度集成,新增 `run_command` 工具在工作空间中可视化执行
-- 📦 构建流程优化:补充手动缓存下载方案,修复 npm install Electron 下载超时问题
-- 📝 更新 docs/BUILD.md:新增故障排查条目和手动缓存完整命令
-
-**v3.2.1** — 日志统一 & 构建优化
-- ♻️ 主进程 5 处 `console.log/error` 全部清除,通过 IPC 接入执行日志面板
-- 📦 移除便携版构建目标,后续仅提供 NSIS 安装包
-- 🗑️ 删除 `docs/V3-TOOL-CALLING.md`,更新 README 和 CHANGELOG
-
-**v3.2.0** — 工作空间面板
-- 🖥️ 右侧可折叠工作空间面板:命令行 Tab + 文件浏览器 Tab
-- 💻 终端界面:实时流式输出、ANSI 颜色支持、多终端 Tab、无超时限制
-- 📁 文件浏览器:目录树、文件预览(带行号)、上级导航
-- 🔌 IPC 双向通信:`on/send` 模式实现流式推送,独立于 Agent Loop 超时体系
-- ⚙️ 设置面板新增工作空间目录配置(默认 `userData/workspace`)
-- 🔒 工作空间安全:复用命令/路径黑名单,窗口关闭自动清理子进程
-- 📦 启动时自动创建工作空间目录
-
-**v3.1.1** — 代码质量 & 桌面原生化
-- ♻️ 渲染进程 58 处 console.log 全部迁移至结构化 log-service
-- 🖥️ 所有文件对话框(上传/保存/导入)改用 Electron 原生对话框
-- 🪙 Header 新增当前会话总 token 消耗统计
-- 🧹 移除浏览器专用代码(fileToBase64/fileToText/downloadFile),统一走 IPC 桥接
-- 🔧 修复 vector-store/importSessions 编译错误,修复 token 统计显示
-- 📦 新增 build-release.sh 一键构建 + restore-build-cache.sh 构建缓存恢复
-
-**v3.0.0-patch2** — 稳定性修复
-- 🔧 Tool Calling IPC 卡死修复(25 秒硬超时 + 多层保护)
-- 🔧 Electron contextIsolation 兼容(自定义 prompt/confirm 弹窗)
-- 🔧 日志面板默认常驻显示,启动日志不再丢失
-- 🔧 新建会话时强制中断生成并恢复发送按钮
-- 📝 对齐 Ollama 官方 Tool Calling 文档
-
-**v3.0.0** — Tool Calling & Agent 记忆系统
-- 🔧 7 个本地工具 + Agent Loop 引擎 + 安全模型
-- 🧠 自动记忆提取 + 跨会话持久化 + 可视化管理
-- ⚡ 记忆检索 → RAG 检索 → Tool Calling 三层协作
+完整更新日志请查看 [docs/CHANGELOG.md](docs/CHANGELOG.md)。
---
diff --git a/docs/BUILD.md b/docs/BUILD.md
index bc6f13f..b55d852 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -14,7 +14,7 @@
# 1. 克隆并安装依赖
git clone https://gitee.com/thzxx/metona-ollama-desktop.git
cd metona-ollama-desktop
-git checkout metona-ollama-desktop-v3.2.3
+git checkout metona-ollama-desktop-v3.2.4
npm config set registry https://registry.npmmirror.com
npm install
@@ -176,6 +176,12 @@ electron-builder 会自动处理所有依赖下载(Electron、NSIS、winCodeSi
## 构建日志
+### v3.2.4(2026-04-08) — metona-ollama-desktop 仓库
+
+- 🔧 AI 命令执行走工作空间终端,实时流式显示
+- 🔧 设置面板修改地址后同步更新顶部导航连接状态
+- ✅ 分支 `metona-ollama-desktop-v3.2.4` 已创建并推送
+
### v3.2.3(2026-04-08) — metona-ollama-desktop 仓库
- ✅ 工作空间面板改为常驻显示,固定宽度 480px
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 2353412..ec57d07 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -3,7 +3,7 @@
## 分支结构
**桌面版主线**(`master`):
-- `metona-ollama-desktop-v1` → `metona-ollama-desktop-v1.1` → `metona-ollama-desktop-v2` → `metona-ollama-desktop-v3` → `metona-ollama-desktop-v3.2.0` → `metona-ollama-desktop-v3.2.1` → `metona-ollama-desktop-v3.2.2` → `metona-ollama-desktop-v3.2.3`(当前)
+- `metona-ollama-desktop-v1` → `metona-ollama-desktop-v1.1` → `metona-ollama-desktop-v2` → `metona-ollama-desktop-v3` → `metona-ollama-desktop-v3.2.0` → `metona-ollama-desktop-v3.2.1` → `metona-ollama-desktop-v3.2.2` → `metona-ollama-desktop-v3.2.3` → `metona-ollama-desktop-v3.2.4`(当前)
**Web 版历史**已独立为 [metona-ollama-web](https://gitee.com/thzxx/metona-ollama-web) 仓库(v1.1.0 ~ v4.0.3)。
@@ -11,6 +11,23 @@
## 桌面版(Desktop)
+### Desktop v3.2.4 — AI 命令执行走工作空间终端 & Bug 修复
+
+- 🔧 **AI 命令执行走工作空间终端**
+ - `run_command` 工具不再在主进程内部执行,改为通过工作空间终端实时执行并显示
+ - stdout/stderr 实时推送到工作空间终端面板,流式展示
+ - 用户可通过停止按钮终止卡住的命令,结果自动回传 AI
+ - 新增 `cmd:output` / `cmd:done` / `cmd:kill` IPC 通道
+- 🖥️ **工作空间面板优化**
+ - 去掉用户手动命令输入,所有命令由 AI 执行
+ - 终端底部状态提示(等待/执行中)
+ - 修复面板顶部对齐(90px → 92px)
+- 🔧 **设置面板修复**
+ - 修改 Ollama 服务地址后同步更新顶部导航连接状态和模型列表
+- 📦 **构建产物**
+ - `Metona Ollama Setup 3.2.4.exe`
+ - Gitee Release v3.2.4-desktop
+
### Desktop v3.2.3 — 工作空间面板常驻显示 & Bug 修复
- 🖥️ **工作空间面板改为常驻显示**
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 0160bac..2f1ce1b 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -170,11 +170,13 @@ import {
### 5.3 工作空间与 AI 集成
-- AI 通过 `run_command` 工具调用工作空间进程管理
-- 执行路径:`tool-registry.ts` → `bridge.workspace.execTool()` → IPC → `workspace.ts execWorkspaceCommand()`
-- 无超时限制,进程自然结束后返回完整输出
-- 输出上限:5MB(超出标记 `truncated=true`)
-- 用户可随时通过设置禁用 `run_command`
+- AI 通过 `run_command` 工具执行命令,命令在工作空间终端实时显示
+- 执行路径:`tool-registry.ts` → `bridge.workspace.execTool()` → IPC `tool:execute` → `handleRunCommand()` → `spawn` 子进程
+- 主进程 `handleRunCommand` 执行命令,stdout/stderr 通过 `cmd:output` 实时推送到渲染进程终端面板
+- 进程结束后通过 `cmd:done` 通知渲染进程,结果回传 AI
+- 用户可通过工作空间面板停止按钮终止命令(`cmd:kill`)
+- 命令执行前经过 `checkCommandAllowed()` 安全检查
+- 用户确认后才执行(Agent Loop 的 `needsConfirmation` 机制)
---
@@ -221,6 +223,24 @@ npm start # 构建并运行
npm run dist # 构建 Windows 安装包
```
+### 版本号更新
+
+更新版本号时,需同步修改以下文件:
+
+| 文件 | 内容 |
+|------|------|
+| `package.json` | `"version": "X.Y.Z"` |
+| `src/renderer/index.html` | `vX.Y.Z` |
+| `README.md` | 版本徽章、下载文件名、构建分支引用 |
+| `docs/BUILD.md` | 构建分支引用、新增构建日志条目 |
+| `docs/CHANGELOG.md` | 版本分支结构链、新增版本说明条目 |
+
+### 更新日志
+
+- **所有更新日志只写在 `docs/CHANGELOG.md`**
+- `README.md` 不写历史版本记录,只保留一行链接指向 `docs/CHANGELOG.md`
+- 版本分支结构链在 `docs/CHANGELOG.md` 顶部维护
+
### 7.2 发布流程
1. 更新 `package.json` 版本号
@@ -239,9 +259,16 @@ npm run dist # 构建 Windows 安装包
- `master` — 稳定发布版本
- `develop` — 开发主分支
+- `metona-ollama-desktop-vX.Y.Z` — 每个版本对应的发布分支
- `feature/*` — 功能开发
- `fix/*` — Bug 修复
+### 提交规则
+
+- **默认只推送到当前所在分支**
+- 需要同步到其他分支时,用户会明确指定
+- 不要自作主张往多个分支推送
+
### Commit 规范
```
diff --git a/package.json b/package.json
index f9a3a57..a864ec8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "3.2.3",
+ "version": "3.2.4",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index b6ad987..a42c95c 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -19,10 +19,11 @@ import {
handleSearchFiles,
handleCreateDir,
handleDeleteFile,
- handleRunCommand
+ handleRunCommand,
+ killToolProcess
} 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 } from './workspace.js';
export function setupIPC(): void {
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
@@ -172,9 +173,10 @@ export function setupIPC(): void {
killProcess(id);
});
- // 无超时工具命令执行(AI Tool Calling 集成)
- ipcMain.handle('workspace:execTool', async (_, command: string, cwd?: string) => {
- sendLog('info', `🔧 workspace:execTool`, `${command.slice(0, 200)} | cwd: ${cwd || '默认'}`);
- return execWorkspaceCommand(command, cwd);
+ // 终止当前 AI 工具命令
+ ipcMain.handle('cmd:kill', async () => {
+ const killed = killToolProcess();
+ sendLog('info', `🔧 cmd:kill`, killed ? '已终止' : '无正在运行的工具命令');
+ return { killed };
});
}
diff --git a/src/main/preload.ts b/src/main/preload.ts
index 1c6293a..0ba6b14 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -59,11 +59,17 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
onExit: (callback: (data: { id: string; code: number | null }) => void) => {
ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data));
},
- /** 无超时命令执行,用于 AI Tool Calling */
- execTool: (command: string, cwd?: string) => ipcRenderer.invoke('workspace:execTool', command, cwd),
- /** 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));
- }
+ /** 无超时工具命令执行,用于 AI Tool Calling */
+ execTool: (command: string, cwd?: string) => ipcRenderer.invoke('tool:execute', 'run_command', { command, cwd }),
+ /** AI 命令实时输出推送到工作空间终端 */
+ onCmdOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => {
+ ipcRenderer.on('cmd:output', (_: unknown, data: { command: string; type: 'stdout' | 'stderr'; data: string }) => callback(data));
+ },
+ /** AI 命令执行完毕 */
+ onCmdDone: (callback: (data: { command: string; exitCode: number | null }) => void) => {
+ ipcRenderer.on('cmd:done', (_: unknown, data: { command: string; exitCode: number | null }) => callback(data));
+ },
+ /** 终止当前 AI 命令 */
+ cmdKill: () => ipcRenderer.invoke('cmd:kill')
}
});
diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts
index 5189687..6d1ff3d 100644
--- a/src/main/tool-handlers.ts
+++ b/src/main/tool-handlers.ts
@@ -5,9 +5,15 @@
import * as fs from 'fs/promises';
import * as path from 'path';
+import { spawn } from 'child_process';
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
import { mainWindow } from './main.js';
-import { execWorkspaceCommand } from './workspace.js';
+import { getWorkspaceDir } from './workspace.js';
+
+/** 发送日志到渲染进程日志面板 */
+function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
+ mainWindow?.webContents.send('main:log', { level, message, detail });
+}
export interface ToolResult {
success: boolean;
@@ -263,39 +269,101 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
}
}
+/** 当前工具命令进程(用于用户手动终止) */
+let _toolProc: ReturnType | null = null;
+
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise {
try {
const cmdCheck = checkCommandAllowed(params.command);
- if (!cmdCheck.ok) return { success: false, error: cmdCheck.reason };
-
- // 通过 workspace 子进程执行,复用安全检查和进程管理
- const result = await execWorkspaceCommand(params.command, params.cwd);
-
- // 将命令和输出同步推送到工作空间终端面板
- if (mainWindow) {
- mainWindow.webContents.send('workspace:toolOutput', {
- command: params.command,
- stdout: result.stdout,
- stderr: result.stderr,
- exitCode: result.exitCode
- });
+ if (!cmdCheck.ok) {
+ sendLog('warn', `🔧 run_command 被拦截`, `${params.command.slice(0, 100)} | ${cmdCheck.reason}`);
+ return { success: false, error: cmdCheck.reason };
}
- return {
- success: result.success,
- stdout: result.stdout,
- stderr: result.stderr,
- exitCode: result.exitCode,
- duration: result.duration
- };
+ const cwd = params.cwd ? path.resolve(params.cwd) : getWorkspaceDir();
+ const dirCheck = checkPathAllowed(cwd, 'read');
+ if (!dirCheck.ok) {
+ sendLog('warn', `🔧 run_command 目录被拦截`, `${cwd} | ${dirCheck.reason}`);
+ return { success: false, error: dirCheck.reason };
+ }
+
+ sendLog('info', `🔧 run_command 执行`, `${params.command.slice(0, 200)} | cwd: ${cwd}`);
+
+ return new Promise((resolve) => {
+ const isWin = process.platform === 'win32';
+ const shell = isWin ? 'cmd.exe' : 'bash';
+ const shellArgs = isWin ? ['/c', params.command] : ['-c', params.command];
+ const startTime = Date.now();
+
+ _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;
+ sendLog(
+ code === 0 ? 'success' : 'warn',
+ `🔧 run_command 完成`,
+ `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B`
+ );
+ 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;
+ sendLog('error', `🔧 run_command 失败`, `${err.message} | ${duration}ms`);
+ 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) {
- const error = err as { message: string };
- return {
- success: false,
- stdout: '',
- stderr: error.message,
- exitCode: 1,
- error: error.message
- };
+ sendLog('error', `🔧 run_command 异常`, (err as Error).message);
+ return { success: false, stdout: '', stderr: (err as Error).message, exitCode: 1, error: (err as Error).message };
}
}
+
+/** 终止当前工具命令进程 */
+export function killToolProcess(): boolean {
+ if (!_toolProc) {
+ sendLog('warn', `🔧 cmd:kill`, '无正在运行的工具命令');
+ return false;
+ }
+ try { _toolProc.kill('SIGTERM'); } catch { /* ignore */ }
+ _toolProc = null;
+ sendLog('info', `🔧 cmd:kill`, '工具命令已终止');
+ return true;
+}
diff --git a/src/main/workspace.ts b/src/main/workspace.ts
index f33f262..c8c11eb 100644
--- a/src/main/workspace.ts
+++ b/src/main/workspace.ts
@@ -208,7 +208,8 @@ export function getActiveProcessCount(): number {
*/
export function execWorkspaceCommand(
command: string,
- cwd?: string
+ cwd?: string,
+ onOutput?: (type: 'stdout' | 'stderr', data: string) => void
): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number; userTerminated: boolean; truncated: boolean }> {
return new Promise((resolve) => {
const workDir = cwd ? path.resolve(cwd) : _workspaceDir;
@@ -255,8 +256,9 @@ export function execWorkspaceCommand(
activeProcesses.set(cmdId, proc);
proc.stdout.on('data', (data: Buffer) => {
+ const str = data.toString();
if (stdoutBuf.length < MAX_OUTPUT) {
- stdoutBuf += data.toString();
+ stdoutBuf += str;
if (stdoutBuf.length > MAX_OUTPUT) {
stdoutBuf = stdoutBuf.slice(0, MAX_OUTPUT);
truncated = true;
@@ -264,11 +266,14 @@ export function execWorkspaceCommand(
} else {
truncated = true;
}
+ // 实时推送到渲染进程
+ onOutput?.('stdout', str);
});
proc.stderr.on('data', (data: Buffer) => {
+ const str = data.toString();
if (stderrBuf.length < MAX_OUTPUT) {
- stderrBuf += data.toString();
+ stderrBuf += str;
if (stderrBuf.length > MAX_OUTPUT) {
stderrBuf = stderrBuf.slice(0, MAX_OUTPUT);
truncated = true;
@@ -276,6 +281,8 @@ export function execWorkspaceCommand(
} else {
truncated = true;
}
+ // 实时推送到渲染进程
+ onOutput?.('stderr', str);
});
proc.on('close', (code) => {
diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts
index 56ae6fe..f2c2a22 100644
--- a/src/renderer/components/settings-modal.ts
+++ b/src/renderer/components/settings-modal.ts
@@ -8,7 +8,7 @@ import { encryptData, decryptData } from '../services/crypto.js';
import { setMemoryEnabled } from '../services/memory-manager.js';
import { setToolEnabled } from '../services/tool-registry.js';
import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js';
-import { updateConnectionInfo, updateRunningModels } from './header.js';
+import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js';
import { loadModels } from './model-bar.js';
import { showToast } from './toast.js';
import { showConfirm } from './prompt-modal.js';
@@ -35,6 +35,7 @@ export function initSettingsModal(): void {
state.set(KEYS.API, api);
if (db) await db.saveSetting('serverUrl', url);
updateConnectionInfo();
+ checkConnection();
loadModels();
}, 500);
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts
index 6162681..d69dc1b 100644
--- a/src/renderer/components/workspace-panel.ts
+++ b/src/renderer/components/workspace-panel.ts
@@ -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}`;
}
@@ -171,10 +182,15 @@ export async function initWorkspacePanel(): Promise {
handleProcessExit(data.id, data.code);
});
- // 监听 AI Tool Calling 命令输出,同步到终端面板
- bridge.workspace.onToolOutput((data) => {
- logDebug('工作空间收到工具输出', `command: ${data.command.slice(0, 50)} | exitCode: ${data.exitCode}`);
- appendToolOutput(data.command, data.stdout, data.stderr, data.exitCode);
+ // 监听 AI 命令实时输出,流式推送到终端面板
+ bridge.workspace.onCmdOutput((data) => {
+ appendToolStreamOutput(data.command, data.type, data.data);
+ });
+
+ // 监听 AI 命令执行完毕
+ bridge.workspace.onCmdDone((data) => {
+ logDebug('工作空间 AI 命令完成', `command: ${data.command.slice(0, 50)} | exitCode: ${data.exitCode}`);
+ handleToolDone(data.command, data.exitCode);
});
// 绑定 UI 事件
@@ -191,28 +207,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,57 +268,30 @@ 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 {
+/** AI Tool Calling 实时输出 → 流式追加到活跃终端 */
+function appendToolStreamOutput(command: string, type: 'stdout' | 'stderr', data: string): void {
const session = getActiveSession();
if (!session) return;
- // 显示执行的命令
- session.lines.push({ type: 'system', text: `$ ${command} (AI)` });
-
- // 显示 stdout
- if (stdout) {
- for (const line of stdout.split('\n')) {
- if (line) session.lines.push({ type: 'stdout', text: line });
- }
+ // 首次输出时显示命令头
+ if (!currentAiCommand) {
+ currentAiCommand = command;
+ session.running = true;
+ session.lines.push({ type: 'system', text: `$ ${command} (AI)` });
+ updateHint();
+ updateStopBtnState();
}
- // 显示 stderr
- if (stderr) {
- for (const line of stderr.split('\n')) {
- if (line) session.lines.push({ type: 'stderr', text: line });
+ // 流式追加输出(按行分割,保留最后一行用于后续追加)
+ const lines = data.split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (i < lines.length - 1 || line) {
+ const cleanLine = line.replace(/\r/g, '');
+ if (cleanLine) session.lines.push({ type, text: cleanLine });
}
}
- // 显示退出状态
- const exitMsg = exitCode === 0 ? '✓ AI 命令执行成功' : `✗ AI 命令退出 (code: ${exitCode})`;
- session.lines.push({ type: 'system', text: exitMsg });
-
// 限制最大行数
if (session.lines.length > 3000) {
session.lines = session.lines.slice(-2000);
@@ -333,6 +300,23 @@ function appendToolOutput(command: string, stdout: string, stderr: string, exitC
renderTerminal();
}
+/** AI Tool Calling 命令执行完毕 */
+function handleToolDone(command: string, exitCode: number | null): void {
+ const session = getActiveSession();
+ if (!session) return;
+
+ // 显示退出状态
+ const exitMsg = exitCode === 0 ? '✓ AI 命令执行成功' : `✗ AI 命令退出 (code: ${exitCode})`;
+ session.lines.push({ type: 'system', text: exitMsg });
+
+ session.running = false;
+ currentAiCommand = null;
+
+ renderTerminal();
+ updateStopBtnState();
+ updateHint();
+}
+
function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data: string): void {
const session = terminalSessions.find(s => s.id === sessionId);
if (!session) {
@@ -389,14 +373,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.cmdKill();
+
+ 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 +651,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 +689,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;
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 95c689a..ecd85f3 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -27,7 +27,7 @@
diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts
index 01b7605..2030887 100644
--- a/src/renderer/services/agent-engine.ts
+++ b/src/renderer/services/agent-engine.ts
@@ -32,9 +32,7 @@ const TOOL_USAGE_GUIDE = `【工具使用规则】
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
-5. 当用户要求执行命令时,使用 run_command 工具执行。run_command 通过工作空间进程管理执行,无超时限制,支持长时间运行的命令。执行完成后,将 stdout/stderr 的结果告知用户。
-6. 如果 run_command 返回 userTerminated=true,说明用户手动终止了命令,请据此告知用户。
-7. 如果返回 truncated=true,说明命令输出超过 5MB 被截断,应告知用户完整输出可在工作空间面板查看。`;
+5. 当用户要求执行命令时,使用 run_command 工具执行。命令通过工作空间终端实时执行并显示,执行完成后将 stdout/stderr 结果告知用户。`;
export interface AgentCallbacks {
onThinking: (text: string) => void;
diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts
index b66c2fe..4213c5e 100644
--- a/src/renderer/services/tool-registry.ts
+++ b/src/renderer/services/tool-registry.ts
@@ -161,14 +161,14 @@ export async function executeTool(toolName: string, args: Record void) => void;
onExit: (callback: (data: { id: string; code: number | null }) => void) => void;
/** 无超时工具命令执行,用于 AI Tool Calling 集成 */
- 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;
+ execTool: (command: string, cwd?: string) => Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null; duration: number }>;
+ /** AI 命令实时输出推送到工作空间终端 */
+ onCmdOutput: (callback: (data: { command: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
+ /** AI 命令执行完毕 */
+ onCmdDone: (callback: (data: { command: string; exitCode: number | null }) => void) => void;
+ /** 终止当前 AI 命令 */
+ cmdKill: () => Promise<{ killed: boolean }>;
};
}