merge: develop into master

This commit is contained in:
thzxx
2026-04-08 13:34:20 +08:00
16 changed files with 335 additions and 245 deletions
+4 -49
View File
@@ -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)。
---
+7 -1
View File
@@ -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.42026-04-08 — metona-ollama-desktop 仓库
- 🔧 AI 命令执行走工作空间终端,实时流式显示
- 🔧 设置面板修改地址后同步更新顶部导航连接状态
- ✅ 分支 `metona-ollama-desktop-v3.2.4` 已创建并推送
### v3.2.32026-04-08 — metona-ollama-desktop 仓库
- ✅ 工作空间面板改为常驻显示,固定宽度 480px
+18 -1
View File
@@ -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 修复
- 🖥️ **工作空间面板改为常驻显示**
+32 -5
View File
@@ -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` | `<span class="app-version">vX.Y.Z</span>` |
| `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 规范
```
+1 -1
View File
@@ -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",
+8 -6
View File
@@ -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 };
});
}
+12 -6
View File
@@ -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')
}
});
+96 -28
View File
@@ -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<typeof spawn> | null = null;
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
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
};
} catch (err) {
const error = err as { message: string };
return {
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: error.message,
stdout,
stderr: stderr + (stderr ? '\n' : '') + err.message,
exitCode: 1,
error: error.message
};
error: err.message,
duration
});
});
});
} catch (err) {
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;
}
+10 -3
View File
@@ -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) => {
+2 -1
View File
@@ -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);
+85 -89
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}`;
}
@@ -171,10 +182,15 @@ export async function initWorkspacePanel(): Promise<void> {
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;
// 显示执行的命令
// 首次输出时显示命令
if (!currentAiCommand) {
currentAiCommand = command;
session.running = true;
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 });
}
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;
// 终止用户终端进程(如果有)
if (session.running) {
bridge.workspace.kill(session.id);
session.lines.push({ type: 'system', text: '■ 已发送终止信号' });
}
// 终止当前 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;
+5 -8
View File
@@ -27,7 +27,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v3.2.2</span>
<span class="app-version">v3.2.4</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -182,13 +182,9 @@
<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>
<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>
@@ -206,6 +202,7 @@
</button>
</div>
</div>
</div>
<!-- 文件 Tab -->
<div class="ws-content" id="wsFilesContent" style="display:none;">
+1 -3
View File
@@ -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;
+3 -5
View File
@@ -161,14 +161,14 @@ export async function executeTool(toolName: string, args: Record<string, unknown
try {
logToolStart(toolName, JSON.stringify(args));
// run_command 走 workspace 无超时 IPC,其他工具走标准 IPC
// run_command 走 workspace IPC
if (toolName === 'run_command') {
const command = args.command as string;
const cwd = args.cwd as string | undefined;
if (!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);
logToolResult('run_command', result.success, result.success ? undefined : result.stderr?.slice(0, 200));
return {
@@ -176,9 +176,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
duration: result.duration,
userTerminated: result.userTerminated,
truncated: result.truncated
duration: result.duration
};
}
+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 {
+7 -3
View File
@@ -240,9 +240,13 @@ export interface MetonaDesktopAPI {
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => 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 }>;
};
}