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) -![版本](https://img.shields.io/badge/version-3.2.3--desktop-brightgreen) +![版本](https://img.shields.io/badge/version-3.2.4--desktop-brightgreen) ![平台](https://img.shields.io/badge/platform-Windows%20x64-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178c6) ![Electron](https://img.shields.io/badge/Electron-33-47848f) @@ -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 @@
Metona Ollama - v3.2.2 + v3.2.4 - - - + 等待 AI 执行命令... +
+ + + +
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 }>; }; }