feat: 工作空间与 AI Tool Calling 深度集成
核心变更: - 新增 execWorkspaceCommand() 无超时进程执行(workspace.ts) - 新增 workspace:execTool IPC 通道(无超时,5MB 输出上限) - run_command 工具改用 workspace IPC,移除 30s 超时限制 - run_command 默认启用(需用户确认),AI 上下文注入工作空间目录 - Agent Loop 工具执行:run_command 无超时,其他工具保持 30s - Agent 系统提示词新增工作空间规则(userTerminated/truncated 反馈) UI 修复: - Header z-index 提升至 50,Model Bar 提升至 40,Input Area 提升至 30 - 工作空间面板 top 调整为 90px(header + model-bar),不再遮挡菜单 - Input Area 增加 position: relative 确保 z-index 生效 其他: - 新增 docs/DEVELOPMENT.md 开发规范文档 - 设置面板 run_command 开关文案优化,默认值改为 true
This commit is contained in:
@@ -0,0 +1,277 @@
|
|||||||
|
# Metona Ollama Desktop — 开发规范
|
||||||
|
|
||||||
|
> 版本: 1.0 | 更新: 2026-04-07 | 维护: 项目团队
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概述
|
||||||
|
|
||||||
|
Metona Ollama Desktop 是基于 TypeScript + Electron 的本地 Ollama AI 桌面客户端,面向 Windows 平台。项目采用零外部依赖策略,所有核心功能(Markdown 解析、SHA-256、HTML 净化器等)均内联实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、技术栈规范
|
||||||
|
|
||||||
|
| 层级 | 技术 | 版本约束 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 语言 | TypeScript | ≥5.7,严格模式 (`strict: true`) |
|
||||||
|
| 桌面框架 | Electron | ≥33 |
|
||||||
|
| 构建工具 | Vite (渲染进程) + tsc (主进程) | Vite ≥5 |
|
||||||
|
| 数据存储 | IndexedDB | 原生 API,无 ORM |
|
||||||
|
| 打包 | electron-builder | NSIS 格式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main/ # Electron 主进程
|
||||||
|
│ ├── main.ts # 入口、窗口管理
|
||||||
|
│ ├── preload.ts # contextBridge API 暴露
|
||||||
|
│ ├── ipc.ts # IPC 处理器(invoke/handle + on/send)
|
||||||
|
│ ├── workspace.ts # 子进程管理、流式输出
|
||||||
|
│ ├── tool-handlers.ts # Tool Calling 工具实现
|
||||||
|
│ ├── tool-security.ts # 路径/命令安全检查
|
||||||
|
│ ├── menu.ts # 原生菜单
|
||||||
|
│ ├── tray.ts # 系统托盘
|
||||||
|
│ └── utils.ts # 工具函数
|
||||||
|
├── renderer/ # 渲染进程
|
||||||
|
│ ├── main.ts # 入口、全局初始化
|
||||||
|
│ ├── types.d.ts # 完整类型定义
|
||||||
|
│ ├── index.html # 入口 HTML
|
||||||
|
│ ├── api/ # API 封装
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ ├── services/ # 核心服务
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── db/ # IndexedDB 封装
|
||||||
|
│ ├── state/ # 状态管理
|
||||||
|
│ └── styles/ # 样式
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、代码规范
|
||||||
|
|
||||||
|
### 4.1 TypeScript 规范
|
||||||
|
|
||||||
|
- **严格模式**:所有文件必须通过 `strict: true` 编译
|
||||||
|
- **类型定义**:所有接口/类型在 `types.d.ts` 中集中定义,禁止 `any` 滥用
|
||||||
|
- **命名约定**:
|
||||||
|
- 文件名:`kebab-case`(如 `tool-registry.ts`)
|
||||||
|
- 类型/接口:`PascalCase`(如 `ToolCallRecord`)
|
||||||
|
- 变量/函数:`camelCase`(如 `getEnabledToolDefinitions`)
|
||||||
|
- 常量:`UPPER_SNAKE_CASE`(如 `MAX_LOOPS`)
|
||||||
|
- 私有成员:`_` 前缀(如 `_workspaceDir`)
|
||||||
|
|
||||||
|
### 4.2 日志规范
|
||||||
|
|
||||||
|
**核心原则:项目中严禁使用 `console.log/error/warn/debug`。所有日志必须通过 `log-service.ts` 输出。**
|
||||||
|
|
||||||
|
日志服务提供以下级别:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { logInfo, logSuccess, logWarn, logError, logDebug } from './services/log-service.js';
|
||||||
|
|
||||||
|
logInfo('操作描述', '可选详情');
|
||||||
|
logSuccess('成功描述', '可选详情');
|
||||||
|
logWarn('警告描述', '可选详情');
|
||||||
|
logError('错误描述', '错误详情');
|
||||||
|
logDebug('调试信息', '可选详情');
|
||||||
|
```
|
||||||
|
|
||||||
|
专用日志函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
logInit, // 初始化日志
|
||||||
|
logSetting, // 设置变更日志
|
||||||
|
logToolStart, // 工具开始执行
|
||||||
|
logToolResult, // 工具执行结果
|
||||||
|
logStream, // 流式输出日志
|
||||||
|
logAgentLoop, // Agent Loop 进度
|
||||||
|
logModelResponse,// 模型响应日志
|
||||||
|
logSession, // 会话操作日志
|
||||||
|
logThink // 思考过程日志
|
||||||
|
} from './services/log-service.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**日志详细度要求:**
|
||||||
|
|
||||||
|
- 每个 IPC 调用必须记录发起方、参数摘要、结果
|
||||||
|
- 文件操作记录路径(脱敏)、大小、操作类型
|
||||||
|
- 工具调用记录工具名、参数摘要、执行时长、结果状态
|
||||||
|
- 错误日志必须包含错误消息和上下文信息
|
||||||
|
- 进程管理记录进程 ID、命令摘要、退出码
|
||||||
|
|
||||||
|
**主进程日志**:通过 `mainWindow?.webContents.send('main:log', ...)` 发送到渲染进程日志面板。
|
||||||
|
|
||||||
|
### 4.3 IPC 规范
|
||||||
|
|
||||||
|
项目使用两种 IPC 模式:
|
||||||
|
|
||||||
|
| 模式 | 用途 | 超时 |
|
||||||
|
|------|------|------|
|
||||||
|
| `invoke/handle` | 请求-响应,同步等待结果 | 有(默认) |
|
||||||
|
| `on/send` | 单向推送,流式通信 | 无 |
|
||||||
|
|
||||||
|
**选择原则:**
|
||||||
|
|
||||||
|
- 需要返回值 → `invoke/handle`
|
||||||
|
- 流式输出/实时推送 → `on/send`
|
||||||
|
- 长时间运行的命令 → `on/send`(避免超时问题)
|
||||||
|
- Tool Calling 工具执行 → `invoke/handle`(通过 workspace IPC 实现无超时)
|
||||||
|
|
||||||
|
### 4.4 安全规范
|
||||||
|
|
||||||
|
#### 文件系统安全
|
||||||
|
|
||||||
|
- 所有文件路径通过 `tool-security.ts` 的 `checkPathAllowed()` 验证
|
||||||
|
- 写操作仅允许在用户目录下进行
|
||||||
|
- 路径黑名单:`/etc`, `/sys`, `/proc`, `~/.ssh`, `~/.gnupg` 等
|
||||||
|
|
||||||
|
#### 命令执行安全
|
||||||
|
|
||||||
|
- 所有命令通过 `tool-security.ts` 的 `checkCommandAllowed()` 验证
|
||||||
|
- 命令黑名单:`rm -rf /`, `mkfs`, `dd`, `shutdown`, 反弹 shell 检测等
|
||||||
|
- 写操作类工具(`write_file`, `delete_file`, `run_command`, `create_directory`)必须用户确认
|
||||||
|
|
||||||
|
#### 前端安全
|
||||||
|
|
||||||
|
- 内置 HTML 净化器(白名单标签 + 属性过滤 + URI 协议检查)
|
||||||
|
- Markdown 链接仅允许 `http:` / `https:` / `mailto:` / `tel:`
|
||||||
|
- 阻止 `javascript:` / `vbscript:` / `data:` 协议注入
|
||||||
|
- `contextIsolation: true` + IPC 白名单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构规范
|
||||||
|
|
||||||
|
### 5.1 四大子系统
|
||||||
|
|
||||||
|
```
|
||||||
|
① Agent 系统 → agent-engine.ts + tool-registry.ts
|
||||||
|
② 记忆系统 → memory-manager.ts
|
||||||
|
③ RAG 知识库 → rag.ts + vector-store.ts + document-processor.ts
|
||||||
|
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Tool Calling 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户消息 → Agent Loop → 模型 tool_calls → 工具确认 → 执行 → 结果回传 → 循环
|
||||||
|
```
|
||||||
|
|
||||||
|
- 最大循环次数:10
|
||||||
|
- 全局超时:5 分钟
|
||||||
|
- run_command:无超时(通过 workspace IPC 执行)
|
||||||
|
- 其他工具:30 秒超时
|
||||||
|
|
||||||
|
### 5.3 工作空间与 AI 集成
|
||||||
|
|
||||||
|
- AI 通过 `run_command` 工具调用工作空间进程管理
|
||||||
|
- 执行路径:`tool-registry.ts` → `bridge.workspace.execTool()` → IPC → `workspace.ts execWorkspaceCommand()`
|
||||||
|
- 无超时限制,进程自然结束后返回完整输出
|
||||||
|
- 输出上限:5MB(超出标记 `truncated=true`)
|
||||||
|
- 用户可随时通过设置禁用 `run_command`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、UI/UX 规范
|
||||||
|
|
||||||
|
### 6.1 设计语言
|
||||||
|
|
||||||
|
- **风格**:Windows 11 Fluent Design 暗色主题
|
||||||
|
- **材质**:Mica 毛玻璃 + Acrylic 亚克力
|
||||||
|
- **字体**:Segoe UI Variable (正文) + Cascadia Mono (代码)
|
||||||
|
- **圆角**:控件 4px,卡片 8px,弹框 12-16px
|
||||||
|
|
||||||
|
### 6.2 Z-Index 层级规范
|
||||||
|
|
||||||
|
| 层级 | 组件 | 值 |
|
||||||
|
|------|------|----|
|
||||||
|
| 最高层 | Toast 通知 | 200 |
|
||||||
|
| 高层 | 模态框 | 100 |
|
||||||
|
| 中层 | Header | 50 |
|
||||||
|
| 中层 | Model Bar | 40 |
|
||||||
|
| 中层 | Input Area | 30 |
|
||||||
|
| 低层 | 工作空间面板 | 10 |
|
||||||
|
| 基础 | 日志面板 | 5 |
|
||||||
|
|
||||||
|
**规则**:新增固定定位元素必须指定 z-index 并在此表中记录。
|
||||||
|
|
||||||
|
### 6.3 响应式规则
|
||||||
|
|
||||||
|
- 工作空间面板宽度:300-700px 可调
|
||||||
|
- 主内容区开启工作空间时:`margin-right: 面板宽度`
|
||||||
|
- 模态框宽度:基础 480px,大号 860px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、构建与发布
|
||||||
|
|
||||||
|
### 7.1 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # 完整构建
|
||||||
|
npm run build:renderer # 仅渲染进程 (Vite)
|
||||||
|
npm run build:main # 仅主进程 (tsc)
|
||||||
|
npm start # 构建并运行
|
||||||
|
npm run dist # 构建 Windows 安装包
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 发布流程
|
||||||
|
|
||||||
|
1. 更新 `package.json` 版本号
|
||||||
|
2. 更新 `docs/CHANGELOG.md`
|
||||||
|
3. 更新 `README.md`(如需)
|
||||||
|
4. 构建并测试:`npm start`
|
||||||
|
5. 构建安装包:`npm run dist`
|
||||||
|
6. 推送到 Gitee:`git add -A && git commit && git push origin develop`
|
||||||
|
7. 创建 Release Tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Git 规范
|
||||||
|
|
||||||
|
### 分支模型
|
||||||
|
|
||||||
|
- `master` — 稳定发布版本
|
||||||
|
- `develop` — 开发主分支
|
||||||
|
- `feature/*` — 功能开发
|
||||||
|
- `fix/*` — Bug 修复
|
||||||
|
|
||||||
|
### Commit 规范
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>: <简要描述>
|
||||||
|
|
||||||
|
类型 (type):
|
||||||
|
- feat: 新功能
|
||||||
|
- fix: Bug 修复
|
||||||
|
- refactor: 重构
|
||||||
|
- style: 样式调整
|
||||||
|
- docs: 文档更新
|
||||||
|
- chore: 构建/工具变更
|
||||||
|
- perf: 性能优化
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```
|
||||||
|
feat: 工作空间与 AI Tool Calling 集成
|
||||||
|
fix: 工作空间面板遮挡 Header 的 z-index 问题
|
||||||
|
refactor: run_command 改用 workspace IPC 无超时执行
|
||||||
|
docs: 新增开发规范文档
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、注意事项
|
||||||
|
|
||||||
|
1. **零外部依赖**:新增功能优先使用内联实现,避免引入 npm 包
|
||||||
|
2. **安全优先**:所有文件/命令操作必须经过安全检查层
|
||||||
|
3. **日志完整**:关键操作必须记录到执行日志面板
|
||||||
|
4. **无超时设计**:工作空间相关操作不设超时,由用户控制生命周期
|
||||||
|
5. **用户确认**:写操作类工具必须弹出确认对话框
|
||||||
|
6. **桌面优先**:API 调用必须检查 `bridge.isDesktop`,非桌面环境优雅降级
|
||||||
+7
-1
@@ -22,7 +22,7 @@ import {
|
|||||||
handleRunCommand
|
handleRunCommand
|
||||||
} from './tool-handlers.js';
|
} from './tool-handlers.js';
|
||||||
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
|
||||||
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
|
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir, execWorkspaceCommand } from './workspace.js';
|
||||||
|
|
||||||
export function setupIPC(): void {
|
export function setupIPC(): void {
|
||||||
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
|
ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => {
|
||||||
@@ -171,4 +171,10 @@ export function setupIPC(): void {
|
|||||||
sendLog('info', `🖥️ workspace:kill`, `ID: ${id}`);
|
sendLog('info', `🖥️ workspace:kill`, `ID: ${id}`);
|
||||||
killProcess(id);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -58,6 +58,8 @@ contextBridge.exposeInMainWorld('metonaDesktop', {
|
|||||||
},
|
},
|
||||||
onExit: (callback: (data: { id: string; code: number | null }) => void) => {
|
onExit: (callback: (data: { id: string; code: number | null }) => void) => {
|
||||||
ipcRenderer.on('workspace:exit', (_: unknown, data: { id: string; code: number | null }) => callback(data));
|
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)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,3 +200,137 @@ export function isProcessAlive(id: string): boolean {
|
|||||||
export function getActiveProcessCount(): number {
|
export function getActiveProcessCount(): number {
|
||||||
return activeProcesses.size;
|
return activeProcesses.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令并收集完整输出(无超时限制)
|
||||||
|
* 用于 AI Tool Calling 集成,通过 IPC invoke/handle 调用
|
||||||
|
* 返回完整的 stdout + stderr + exitCode
|
||||||
|
*/
|
||||||
|
export function execWorkspaceCommand(
|
||||||
|
command: string,
|
||||||
|
cwd?: string
|
||||||
|
): 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;
|
||||||
|
const cmdId = `_tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
// 安全检查
|
||||||
|
const cmdCheck = checkCommandAllowed(command);
|
||||||
|
if (!cmdCheck.ok) {
|
||||||
|
sendLog('warn', `🔧 工具命令被拦截`, `${command.slice(0, 100)} | ${cmdCheck.reason}`);
|
||||||
|
resolve({ success: false, stdout: '', stderr: cmdCheck.reason!, exitCode: 1, duration: 0, userTerminated: false, truncated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirCheck = checkPathAllowed(workDir, 'read');
|
||||||
|
if (!dirCheck.ok) {
|
||||||
|
sendLog('warn', `🔧 工具目录被拦截`, `${workDir} | ${dirCheck.reason}`);
|
||||||
|
resolve({ success: false, stdout: '', stderr: dirCheck.reason!, exitCode: 1, duration: 0, userTerminated: false, truncated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(workDir)) {
|
||||||
|
resolve({ success: false, stdout: '', stderr: `工作目录不存在: ${workDir}`, exitCode: 1, duration: 0, userTerminated: false, truncated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendLog('info', `🔧 execWorkspaceCommand`, `${command.slice(0, 200)} | cwd: ${workDir}`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const MAX_OUTPUT = 5 * 1024 * 1024; // 5MB 输出上限
|
||||||
|
let stdoutBuf = '';
|
||||||
|
let stderrBuf = '';
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isWin = process.platform === 'win32';
|
||||||
|
const shell = isWin ? 'cmd.exe' : 'bash';
|
||||||
|
const shellArgs = isWin ? ['/c', command] : ['-c', command];
|
||||||
|
const proc = spawn(shell, shellArgs, {
|
||||||
|
cwd: workDir,
|
||||||
|
env: { ...process.env, ...(isWin ? {} : { TERM: 'xterm-256color' }) },
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
activeProcesses.set(cmdId, proc);
|
||||||
|
|
||||||
|
proc.stdout.on('data', (data: Buffer) => {
|
||||||
|
if (stdoutBuf.length < MAX_OUTPUT) {
|
||||||
|
stdoutBuf += data.toString();
|
||||||
|
if (stdoutBuf.length > MAX_OUTPUT) {
|
||||||
|
stdoutBuf = stdoutBuf.slice(0, MAX_OUTPUT);
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.on('data', (data: Buffer) => {
|
||||||
|
if (stderrBuf.length < MAX_OUTPUT) {
|
||||||
|
stderrBuf += data.toString();
|
||||||
|
if (stderrBuf.length > MAX_OUTPUT) {
|
||||||
|
stderrBuf = stderrBuf.slice(0, MAX_OUTPUT);
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
activeProcesses.delete(cmdId);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
sendLog(
|
||||||
|
code === 0 ? 'success' : 'warn',
|
||||||
|
`🔧 execWorkspaceCommand 完成`,
|
||||||
|
`code: ${code} | ${duration}ms | stdout: ${stdoutBuf.length}B | stderr: ${stderrBuf.length}B`
|
||||||
|
);
|
||||||
|
resolve({
|
||||||
|
success: code === 0,
|
||||||
|
stdout: stdoutBuf,
|
||||||
|
stderr: stderrBuf,
|
||||||
|
exitCode: code,
|
||||||
|
duration,
|
||||||
|
userTerminated: false,
|
||||||
|
truncated
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
activeProcesses.delete(cmdId);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
sendLog('error', `🔧 execWorkspaceCommand 失败`, `${cmdId} | ${err.message}`);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
stdout: stdoutBuf,
|
||||||
|
stderr: stderrBuf + (stderrBuf ? '\n' : '') + `进程启动失败: ${err.message}`,
|
||||||
|
exitCode: 1,
|
||||||
|
duration,
|
||||||
|
userTerminated: false,
|
||||||
|
truncated
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
sendLog('error', `🔧 execWorkspaceCommand 异常`, (err as Error).message);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
stdout: stdoutBuf,
|
||||||
|
stderr: (err as Error).message,
|
||||||
|
exitCode: 1,
|
||||||
|
duration,
|
||||||
|
userTerminated: false,
|
||||||
|
truncated: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终止 execWorkspaceCommand 启动的进程
|
||||||
|
* @returns 是否成功终止
|
||||||
|
*/
|
||||||
|
export function terminateWorkspaceCommand(commandId: string): boolean {
|
||||||
|
return killProcess(commandId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ export function initSettingsModal(): void {
|
|||||||
if (db) await db.saveSetting('runCommandEnabled', toggleRunCommand.checked);
|
if (db) await db.saveSetting('runCommandEnabled', toggleRunCommand.checked);
|
||||||
setToolEnabled('run_command', toggleRunCommand.checked);
|
setToolEnabled('run_command', toggleRunCommand.checked);
|
||||||
if (toggleRunCommand.checked) {
|
if (toggleRunCommand.checked) {
|
||||||
showToast('⚠️ 命令执行已开启,请谨慎使用', 'warning');
|
showToast('命令执行已开启(每次执行需确认)', 'info');
|
||||||
logWarn('命令执行已开启(高风险)');
|
logInfo('命令执行已开启');
|
||||||
} else {
|
} else {
|
||||||
|
showToast('命令执行已关闭', 'info');
|
||||||
logInfo('命令执行已关闭');
|
logInfo('命令执行已关闭');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
|
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
|
||||||
state.set('toolCallingEnabled', false);
|
state.set('toolCallingEnabled', false);
|
||||||
state.set('runCommandEnabled', false);
|
state.set('runCommandEnabled', true); // 默认开启(需确认)
|
||||||
state.set('memoryEnabled', true);
|
state.set('memoryEnabled', true);
|
||||||
state.set('thinkEnabled', false);
|
state.set('thinkEnabled', false);
|
||||||
|
|
||||||
@@ -245,10 +245,10 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
// ── Tool Calling 设置 ──
|
// ── Tool Calling 设置 ──
|
||||||
const toolCallingEnabled = await db.getSetting('toolCallingEnabled', false);
|
const toolCallingEnabled = await db.getSetting('toolCallingEnabled', false);
|
||||||
const runCommandEnabled = await db.getSetting('runCommandEnabled', false);
|
const runCommandEnabled = await db.getSetting('runCommandEnabled', true); // 默认开启(需确认)
|
||||||
state.set('toolCallingEnabled', toolCallingEnabled);
|
state.set('toolCallingEnabled', toolCallingEnabled);
|
||||||
state.set('runCommandEnabled', runCommandEnabled);
|
state.set('runCommandEnabled', runCommandEnabled);
|
||||||
if (runCommandEnabled) setToolEnabled('run_command', true);
|
if (!runCommandEnabled) setToolEnabled('run_command', false); // 仅在用户明确关闭时禁用
|
||||||
const toggleTC = document.querySelector<HTMLInputElement>('#toggleToolCalling');
|
const toggleTC = document.querySelector<HTMLInputElement>('#toggleToolCalling');
|
||||||
if (toggleTC) toggleTC.checked = toolCallingEnabled;
|
if (toggleTC) toggleTC.checked = toolCallingEnabled;
|
||||||
const toggleRC = document.querySelector<HTMLInputElement>('#toggleRunCommand');
|
const toggleRC = document.querySelector<HTMLInputElement>('#toggleRunCommand');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js';
|
import { logInfo, logWarn, logError, logToolStart, logToolResult, logThink, logStream, logAgentLoop, logModelResponse } from './log-service.js';
|
||||||
|
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||||
import type {
|
import type {
|
||||||
OllamaMessage,
|
OllamaMessage,
|
||||||
OllamaStreamChunk,
|
OllamaStreamChunk,
|
||||||
@@ -31,7 +32,9 @@ const TOOL_USAGE_GUIDE = `【工具使用规则】
|
|||||||
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
|
2. 工具调用结果中的 path 字段是权威的文件路径,优先使用它。
|
||||||
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
|
3. 如果对话中从未出现过相关路径,应先用 search_files 或 list_directory 定位,不要凭空猜测路径。
|
||||||
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
|
4. 对同一文件的连续操作(读取→修改、读取→删除),路径必须一致。
|
||||||
5. 当用户要求执行命令时,使用 run_command 工具执行。执行完成后,将 stdout/stderr 的结果告知用户。对于需要实时查看进度的长时间运行命令(如 npm install、ollama pull),建议用户在工作空间面板中手动执行。`;
|
5. 当用户要求执行命令时,使用 run_command 工具执行。run_command 通过工作空间进程管理执行,无超时限制,支持长时间运行的命令。执行完成后,将 stdout/stderr 的结果告知用户。
|
||||||
|
6. 如果 run_command 返回 userTerminated=true,说明用户手动终止了命令,请据此告知用户。
|
||||||
|
7. 如果返回 truncated=true,说明命令输出超过 5MB 被截断,应告知用户完整输出可在工作空间面板查看。`;
|
||||||
|
|
||||||
export interface AgentCallbacks {
|
export interface AgentCallbacks {
|
||||||
onThinking: (text: string) => void;
|
onThinking: (text: string) => void;
|
||||||
@@ -83,6 +86,15 @@ export async function runAgentLoop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注入工作空间上下文
|
||||||
|
const workspaceDir = getWorkspaceDirPath();
|
||||||
|
if (workspaceDir) {
|
||||||
|
systemPromptParts.push(`【工作空间】
|
||||||
|
当前工作空间目录: ${workspaceDir}
|
||||||
|
你可以使用 run_command 工具在此目录下执行命令,所有命令通过工作空间进程管理执行,无超时限制。
|
||||||
|
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
|
||||||
|
}
|
||||||
|
|
||||||
if (systemPromptParts.length > 0) {
|
if (systemPromptParts.length > 0) {
|
||||||
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') + '\n\n' + TOOL_USAGE_GUIDE });
|
messages.push({ role: 'system', content: systemPromptParts.join('\n\n') + '\n\n' + TOOL_USAGE_GUIDE });
|
||||||
} else {
|
} else {
|
||||||
@@ -287,16 +299,23 @@ export async function runAgentLoop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 工具执行超时保护
|
// 工具执行(run_command 无超时,其他工具 30 秒超时)
|
||||||
logInfo(`执行工具: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
logInfo(`执行工具: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
||||||
const result = await Promise.race([
|
let result: ToolResult;
|
||||||
executeTool(call.function.name, call.function.arguments).catch(err =>
|
if (call.function.name === 'run_command') {
|
||||||
({ success: false, error: err?.message || String(err) }) as ToolResult
|
// run_command 通过 workspace IPC 执行,无超时限制
|
||||||
),
|
result = await executeTool(call.function.name, call.function.arguments);
|
||||||
new Promise<ToolResult>((_, reject) =>
|
} else {
|
||||||
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
|
// 其他工具保持 30 秒超时保护
|
||||||
)
|
result = await Promise.race([
|
||||||
]);
|
executeTool(call.function.name, call.function.arguments).catch(err =>
|
||||||
|
({ success: false, error: err?.message || String(err) }) as ToolResult
|
||||||
|
),
|
||||||
|
new Promise<ToolResult>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('工具执行超时(30秒)')), TOOL_EXEC_TIMEOUT)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
const record: ToolCallRecord = {
|
const record: ToolCallRecord = {
|
||||||
name: call.function.name,
|
name: call.function.name,
|
||||||
arguments: call.function.arguments,
|
arguments: call.function.arguments,
|
||||||
|
|||||||
@@ -108,14 +108,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'run_command',
|
name: 'run_command',
|
||||||
description: 'Execute a shell command. DANGEROUS: disabled by default, must be enabled in settings.',
|
description: 'Execute a shell command via workspace. No timeout limit — runs until the process exits. Requires user confirmation. Output is streamed in real-time through the workspace process.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['command'],
|
required: ['command'],
|
||||||
properties: {
|
properties: {
|
||||||
command: { type: 'string', description: 'Shell command to execute.' },
|
command: { type: 'string', description: 'Shell command to execute.' },
|
||||||
cwd: { type: 'string', description: 'Working directory.' },
|
cwd: { type: 'string', description: 'Working directory. Defaults to workspace directory.' }
|
||||||
timeout: { type: 'integer', description: 'Timeout ms. Max 120000.' }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,8 @@ export function needsConfirmation(toolName: string): boolean {
|
|||||||
|
|
||||||
let enabledTools: Set<string> = new Set([
|
let enabledTools: Set<string> = new Set([
|
||||||
'read_file', 'list_directory', 'search_files',
|
'read_file', 'list_directory', 'search_files',
|
||||||
'write_file', 'create_directory', 'delete_file'
|
'write_file', 'create_directory', 'delete_file',
|
||||||
|
'run_command' // 默认启用,需要用户确认
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function setToolEnabled(toolName: string, enabled: boolean): void {
|
export function setToolEnabled(toolName: string, enabled: boolean): void {
|
||||||
@@ -160,7 +160,29 @@ export async function executeTool(toolName: string, args: Record<string, unknown
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logToolStart(toolName, JSON.stringify(args));
|
logToolStart(toolName, JSON.stringify(args));
|
||||||
// IPC 调用 + 25 秒硬超时(比 agent-engine 的 30 秒先触发)
|
|
||||||
|
// run_command 走 workspace 无超时 IPC,其他工具走标准 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)}`);
|
||||||
|
const result = await bridge.workspace.execTool(command, cwd);
|
||||||
|
logToolResult('run_command', result.success, result.success ? undefined : result.stderr?.slice(0, 200));
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
duration: result.duration,
|
||||||
|
userTerminated: result.userTerminated,
|
||||||
|
truncated: result.truncated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他工具:IPC 调用 + 25 秒硬超时
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
bridge.tool.execute(toolName, args),
|
bridge.tool.execute(toolName, args),
|
||||||
new Promise<ToolResult>((_, reject) =>
|
new Promise<ToolResult>((_, reject) =>
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ html, body {
|
|||||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 10;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -392,6 +392,8 @@ html, body {
|
|||||||
background: var(--bg-layer-alt);
|
background: var(--bg-layer-alt);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
z-index: 40;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-bar-icon {
|
.model-bar-icon {
|
||||||
@@ -1046,7 +1048,8 @@ html, body {
|
|||||||
backdrop-filter: saturate(180%) blur(20px);
|
backdrop-filter: saturate(180%) blur(20px);
|
||||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
z-index: 20;
|
z-index: 30;
|
||||||
|
position: relative;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2664,12 +2667,13 @@ html, body {
|
|||||||
|
|
||||||
.workspace-panel {
|
.workspace-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 90px; /* header(44px) + model-bar(46px) */
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 420px;
|
width: 420px;
|
||||||
background: var(--bg-solid, #202020);
|
background: var(--bg-solid, #202020);
|
||||||
border-left: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
border-left: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||||
|
border-top: 1px solid var(--border-default, rgba(255,255,255,0.08));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|||||||
Vendored
+2
@@ -239,6 +239,8 @@ export interface MetonaDesktopAPI {
|
|||||||
kill: (id: string) => void;
|
kill: (id: string) => void;
|
||||||
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
|
onOutput: (callback: (data: { id: string; type: 'stdout' | 'stderr'; data: string }) => void) => void;
|
||||||
onExit: (callback: (data: { id: string; code: number | null }) => 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 }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user