LobeHub Desktop 本地工具开发指南:从 Manifest 到 IPC Controller 的完整实现路径
在 LobeHub 的桌面端(Electron 应用)中,Agent 之所以能够读写、搜索、移动本地文件并执行 shell 命令,依赖的是一套分层清晰的"本地工具"(Local Tools)实现体系。本文基于仓库内的开发参考文档 local-tools.md,完整讲解新增一个桌面本地工具的六步标准流程:定义工具清单(Manifest)→ 定义类型 → 实现 Store Action → 实现 Service 层 → 实现主进程 Controller(IPC Handler)→ 更新 Agent 文档,并结合当前仓库中的真实实现(LocalFileCtr、localFileService 等)剖析每层的调用关系与安全边界,读完即可在 Desktop 应用中完整落地一个可被 Agent 调用的本地工具。
一、总体工作流:六步分层架构
参考文档给出的标准流程如下:
- Define tool interface (Manifest) —— 定义工具接口(工具清单)
- Define related types —— 定义相关类型
- Implement Store Action —— 实现渲染进程的 Store Action
- Implement Service Layer —— 实现 IPC Service 层
- Implement Controller (IPC Handler) —— 实现主进程 Controller
- Update Agent documentation —— 更新 Agent 的 systemRole 文档
这条流程的本质是 Electron 典型的渲染进程 ↔ IPC ↔ 主进程分层:渲染进程里的 AI 对话 Store 只负责状态流转与消息更新,Service 层负责把调用桥接到 Electron IPC,真正接触文件系统、shell、dialog 等系统能力的逻辑全部收敛在主进程 Controller 中。这样做的好处是:敏感操作不暴露在渲染进程、类型贯穿三层可被 TypeScript 静态校验、同一能力可以复用给不同入口(例如文档与代码中提到的渲染进程 IPC 路径和 gateway RPC 路径可以共享同一套缓存与实现)。
二、Step 1:定义工具接口(Manifest)
文档要求在 src/tools/[tool_category]/index.ts 中定义工具的 API 名称常量与 Manifest。以 local-files 分类为例:
// src/tools/local-files/index.ts
export const LocalFilesApiName = {
RenameFile: 'renameFile',
MoveFile: 'moveFile',
} as const;
export const LocalFilesManifest = {
api: [
{
name: LocalFilesApiName.RenameFile,
description: 'Rename a local file',
parameters: {
type: 'object',
properties: {
oldPath: { type: 'string', description: 'Current file path' },
newName: { type: 'string', description: 'New file name' },
},
required: ['oldPath', 'newName'],
},
},
],
};
要点:
LocalFilesApiName用as const固化 API 名,避免散落字符串;Manifest 的parameters直接采用 JSON Schema 风格(type/properties/required),这是给模型做 function calling 的参数契约,description字段会直接进入工具描述,写清楚参数语义能显著影响模型调用正确率;- 一个"分类"(tool_category)对应一个目录,目录内
index.ts集中导出该分类下所有 API 的 Manifest。
需要说明的是,文档中的 src/tools/local-files/ 是该流程的示例位置;在当前仓库中,工具参数与状态类型大多沉淀在共享包内(如 electron-client-ipc 类型定义),Manifest 与 systemRole 的组织以各 builtin-tool-* 包(如 packages/builtin-tools/、packages/builtin-tool-local-system/)为准,新增工具时应先查看相邻工具包的实际组织方式再落位。
三、Step 2:定义相关类型
类型分两处定义,一处放共享包、一处放工具自身:
// packages/electron-client-ipc/src/types.ts
export interface RenameLocalFileParams {
oldPath: string;
newName: string;
}
// src/tools/local-files/type.ts
export interface LocalRenameFileState {
success: boolean;
error?: string;
oldPath: string;
newPath: string;
}
RenameLocalFileParams放在 electron-client-ipc 包中,是因为它要跨渲染/主进程边界传输,两边都从同一包 import,保证参数结构一致;LocalRenameFileState是渲染进程内部的工具执行状态,供 Store 与消息卡片渲染使用。
当前仓库中,localFileService.ts 从 @lobechat/electron-client-ipc 一次性导入了几十个 Params/Result 类型对(LocalReadFileParams、RenameLocalFileParams、EditLocalFileParams、RunCommandParams 等),每个能力都遵循"XxxParams + XxxResult"的成对约定,这套约定正是本步骤的直接产物。
四、Step 3:实现 Store Action
Store Action 位于渲染进程(示例位置 src/store/chat/slices/builtinTool/actions/localFile.ts),职责是:切换 loading 态 → 调用 Service → 把结果写回插件状态与消息内容:
// src/store/chat/slices/builtinTool/actions/localFile.ts
renameLocalFile: async (id: string, params: RenameLocalFileParams) => {
const { toggleLocalFileLoading, updatePluginState, internal_updateMessageContent } = get();
toggleLocalFileLoading(id, true);
try {
const result = await localFileService.renameFile(params);
if (result.success) {
updatePluginState(id, { success: true, ...result });
internal_updateMessageContent(id, JSON.stringify({ success: true }));
} else {
updatePluginState(id, { success: false, error: result.error });
internal_updateMessageContent(id, JSON.stringify({ error: result.error }));
}
return result.success;
} catch (e) {
console.error(e);
updatePluginState(id, { success: false, error: e.message });
return false;
} finally {
toggleLocalFileLoading(id, false);
}
},
三个关键动作:
toggleLocalFileLoading(id, true/false):在finally中必然复位,保证 UI loading 状态不悬挂;updatePluginState(id, ...):更新该消息对应的工具卡片状态,成功时展开完整结果(如newPath),失败时只带error;internal_updateMessageContent(id, ...):把结构化结果({"success":true}或{"error":"..."})写回消息内容,供模型后续轮次读取工具执行结果。
当前仓库中,builtinTool 的 actions 实际收敛在 builtinTool/actions 目录 下(含 search.ts、interpreter.ts 等),新增本地工具动作时可参考同目录现有切片的写法保持一致。
五、Step 4:实现 Service 层
Service 层是渲染进程里最薄的一层,只负责把参数通过 IPC 转发给主进程:
// src/services/electron/localFileService.ts
import { ensureElectronIpc } from '@/utils/electron/ipc';
const ipc = ensureElectronIpc();
export const localFileService = {
renameFile: (params: RenameLocalFileParams) => ipc.localFiles.renameFile(params),
};
ensureElectronIpc()返回 preload 注入的 typed IPC 客户端,ipc.localFiles/ipc.localSystem等命名空间与主进程 Controller 的groupName一一对应;- 这一层禁止写业务逻辑,业务逻辑要么在主进程 Controller,要么在渲染进程的 UI 组件里。
当前仓库的 LocalFileService 已经长成一个覆盖完整能力面的 Service 类,按功能分组:
| 分组 | 代表方法 | 对应 IPC 命名空间 |
|---|---|---|
| 文件操作 | listLocalFiles / readLocalFile / moveLocalFiles / renameLocalFile / writeFile / editLocalFile |
localSystem |
| 文件搜索 | searchLocalFiles / grepContent / globFiles / searchProjectFiles |
localSystem |
| 打开与对话框 | openLocalFile / openLocalFolder / showSaveDialog |
localSystem |
| Shell 命令 | runCommand / getCommandOutput / killCommand / installSandbox |
shellCommand |
| 预览 | getLocalFilePreview / readLocalFileBytes |
localSystem |
一个值得注意的细节:listProjectSkills 走的是 ipc.workspace 命名空间而非 localSystem,源码注释明确说明"项目技能扫描位于主进程 WorkspaceCtr(workspace group),从 LocalFileCtr 中拆分出去"——这说明 Controller 的职责划分是按领域归属而非"所有文件操作都放一个类"来维护的。
六、Step 5:实现主进程 Controller(IPC Handler)
文档给出的最小 Controller 示例:
// apps/desktop/src/main/controllers/LocalFileCtr.ts
import * as fs from 'fs/promises';
import * as path from 'path';
import { ControllerModule, IpcMethod } from '@/controllers';
export default class LocalFileCtr extends ControllerModule {
static override readonly groupName = 'localFiles';
@IpcMethod()
async renameFile(params: RenameLocalFileParams) {
const { oldPath, newName } = params;
const newPath = path.join(path.dirname(oldPath), newName);
try {
await fs.rename(oldPath, newPath);
return { success: true, newPath };
} catch (error) {
return { success: false, error: error.message };
}
}
}
框架机制:ControllerModule 与 IpcMethod
从 controllers/index.ts 可以看到:ControllerModule 继承自 IpcService,构造时注入全局 App 实例;IpcMethod 装饰器(从 @/utils/ipc 导出)负责把方法注册到 IPC 路由。groupName 静态属性决定该 Controller 在 IPC 客户端上暴露的命名空间,因此改 groupName 必须同步改 Service 层的 ipc.xxx 字段,否则运行时才暴露断链。
当前实现:LocalFileCtr 的纵深细节
当前仓库的 LocalFileCtr.ts 已经远超"rename 一个文件"的雏形,其 groupName = 'localSystem',可从中读出若干生产级实践:
- 底层能力下沉到共享包:
readLocalFile、writeLocalFile、renameLocalFile、moveLocalFiles、editLocalFile、expandTilde、resolveAgainstCwd等函数统一从 local-file-shell 包 导入,Controller 只做日志与分支编排,便于单测与复用; - 图片读取的特殊通道:
readFile对png/jpeg/webp/gif/avif等扩展名不走文本读取,而是校验大小(超过MAX_IMAGE_READ_BYTES = 10MB直接拒绝)、经RemoteFileUploadService上传后返回[Image: filename]占位与imageUrl,让 Agent 能"看到"图片;上传失败时降级为明确的占位提示而不是抛错; - 安全路径审计:
auditSafePaths只允许落在SAFE_PATH_PREFIXES = ['/tmp', '/var/tmp']之下的路径,且会用realpath解析符号链接后二次校验,防止通过软链逃逸沙箱; - 预览大小闸门:
MAX_DOCUMENT_PREVIEW_BYTES = 20MB,超限文档走无内容的binary/pdf变体(base64 会让 payload 膨胀约 4/3,必须能装进单条 IPC/RPC 响应); - 项目文件索引:
getProjectFileIndex优先用git rev-parse --show-toplevel+git ls-files(含 tracked/untracked/ignored 三路并行,各带超时)构建索引,非 git 目录回退到 glob 引擎(上限PROJECT_FILE_GLOB_LIMIT = 5000),并顺带把项目根加入预览白名单(approveProjectRootForPreview); - 统一错误形态:每个 handler 都返回
{ success, error? }结构而非抛异常,与 Store Action 中result.success的分支处理正好闭环。
七、Step 6:更新 Agent 文档
流程的最后一步在 src/tools/[tool_category]/systemRole.ts:把新工具的描述加入 <core_capabilities>,使用指引加入 <tool_usage_guidelines>。这一步决定了模型"知道有这把锤子、并且知道什么时候该用"——Manifest 里的 description 与 systemRole 中的指引应当语义一致,避免两处描述漂移。当前仓库中各工具包(如 packages/builtin-tool-local-system/)内均有自己的 systemRole 组织,可直接参照其章节结构补充。
八、落地检查清单
按六步流程新增一个 Desktop 本地工具时,建议对照以下清单逐项确认:
- Manifest 中 API 名、参数 JSON Schema、
required列表与类型定义一致; - 跨进程参数类型放在 electron-client-ipc 共享包,两端 import 同一份;
- Store Action 有 loading 开关且
finally复位,成功/失败都调用updatePluginState并回写消息内容; - Service 层只做
ipc.<groupName>.<method>(params)透传,无业务逻辑; - Controller 的
groupName与 Service 端命名空间匹配,handler 返回{ success, error? }统一形态,系统级能力(fs、dialog、shell)只出现在主进程; - systemRole 文档同步更新
<core_capabilities>与<tool_usage_guidelines>。
关键文件索引:
- 流程文档:.agents/skills/desktop/references/local-tools.md
- 主进程 Controller:apps/desktop/src/main/controllers/LocalFileCtr.ts、apps/desktop/src/main/controllers/index.ts
- 渲染进程 Service:src/services/electron/localFileService.ts
- Store Actions 目录:src/store/chat/slices/builtinTool/actions/index.ts
- IPC 类型包:packages/electron-client-ipc
- 文件操作共享包:packages/local-file-shell
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00