merge: develop into master
This commit is contained in:
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+18
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
});
|
||||
|
||||
+97
-29
@@ -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
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
+10
-3
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 显示执行的命令
|
||||
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;
|
||||
|
||||
+20
-23
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+7
-3
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user