WeGirlOffice 插件开发指南
面向插件开发者,介绍「如何开发插件、如何把功能注册到界面、如何调用宿主能力」。
- 聚焦 怎么用、怎么写,不深入宿主内部实现。
- 所有界面能力都通过
plugin.json的contributes声明式注册,宿主负责发现、门控与渲染,你不需要在宿主代码里写特例分支。 - 插件运行时是 Cordis:宿主为每个插件开一个 Cordis 子上下文,调用你的
apply(ctx)注入能力;卸载时由 Cordis 触发dispose,自动回收你注册的一切(cap / 面板 / DSL / Agent 工具 / IPC)。 - 源码在外部工作区
wegirl-office/plugins/<id>/,构建产物在wegirl-office/plugins/dist/<id>/(运行时加载产物,不是源码)。
目录
- 心智模型:插件能做什么
- Cordis 运行时模型(必读)
- 目录与源码 / 产物位置
- 构建、链接与调试命令
- plugin.json 清单字段
- 贡献类型(contributes)
- 渲染端组件开发
- 调用宿主能力:@wegirl/sdk 与 cap
- 打开中央面板:panelItem 接口
- 组件互调指南:Panel / Editor / 会话
- 主进程能力:main.cjs(Cordis 插件)
- 跨进程能力:ctx.cap.define(推荐)
- 场景(scenario)开发
- 常见问题与踩坑
- 完整示例:sandbox 顶层模式插件
1. 心智模型:插件能做什么
插件通过 plugin.json 的 contributes 向宿主贡献界面能力,通过 main.cjs 的 apply(ctx) 向宿主贡献主进程能力(cap / Agent 工具 / 项目初始化钩子 / 剪贴板路由)。
| 能力 | 注册位置 | 说明 |
|---|---|---|
| 中间面板 | contributes.panels |
在中间工作区渲染(可同时出现在左侧项目树 + 顶部 tab,或仅动态展开)。 |
| 文件编辑器 | contributes.fileEditors |
把特定文件类型(按后缀 / 路径)映射到专属编辑器组件。 |
| 顶层模式 | contributes.modes |
声明一个顶层模式(如沙盘),宿主的模式选择器从硬编码 office 扩展为「内置 office + 插件 modes」,按需懒加载整份插件产物(典型用例:重型依赖 Phaser 只在进入沙盘时下载)。 |
| 技能 | contributes.skills |
把 skills/<name>/SKILL.md 随项目创建复制到 .agents/skills/<name>。 |
| 主进程能力 | main.cjs → apply(ctx) |
跨进程 cap、本地存储、Agent 工具、项目初始化钩子、剪贴板路由(见第 11、12 节)。 |
一个插件可同时贡献多个挂载点。场景归属用语顶层
scenario声明,宿主自动按场景门控。
2. Cordis 运行时模型(必读)
宿主用 Cordis 作为插件运行时。理解下面三点,能避开 90% 的坑:
2.1 加载契约:导出 apply
main.cjs 必须导出 apply(Cordis 风格):
'use strict';
module.exports = {
// ✅ 唯一入口:宿主用 Cordis 子上下文调用,ctx 上能取所有能力服务
apply (ctx) {
ctx.cap.define('myPlugin', {
impl: { ping: async (payload) => ({ echo: payload }) }
});
}
};
- 只导出
apply,所有初始化逻辑放进apply内部;不要写顶层副作用(保证ctx注入完成后再执行)。缺apply的插件会被宿主报错拒绝加载。
2.2 生命周期 / 卸载:统一走 dispose
Cordis 卸载插件时只触发 dispose。宿主在子上下文上为此挂了统一回收器,会自动摘掉该插件注册的一切:
ctx.cap注册的能力(hostCap.removeByOwner(pluginId))- 声明式贡献
contributes.*(面板 / 文件编辑器 / 模式 / 技能) ctx.harness注册的 DSL 节点 / 工具(dslRegistry.unregisterByOwner)
你只需要清理「自己 new 出来的句柄」(定时器、子进程、原生窗口监听、第三方连接),注册表类资源不必手动撤。两种注册 dispose 清理的方式,任选其一:
module.exports = {
apply (ctx) {
// 方式 A:返回 { dispose }(宿主会捕获并在卸载时调用)
const timer = setInterval(...);
return {
dispose () { clearInterval(timer); }
};
// 方式 B:直接挂 ctx.on('dispose')(等价,且可注册多个)
// const timer = setInterval(...);
// ctx.on('dispose', () => clearInterval(timer));
}
};
2.3 ctx 上哪些是 Cordis 服务、哪些是宿主注入
这是最容易混淆的地方,先记结论:ctx.cap / ctx.db / ctx.harness / ctx.electron / ctx.file / ctx.network 是 Cordis 服务(根上下文注册、子上下文自动继承);下面这组是宿主在 apply(ctx) 调用前手动注入的普通字段/方法(不是 Cordis API,也更不是 Node 全局):
| 注入项 | 类型 | 说明 |
|---|---|---|
ctx.pluginId / ctx.manifest |
字段 | 本插件 id / 完整清单 |
ctx.pluginDir |
字段 | 插件目录绝对路径(读自己的子模块用) |
ctx.dataDir |
字段 | 插件私有数据目录 ~/.wegirl-office/plugin-data/<id>/,卸载不删、升级不丢 |
ctx.logger |
字段 | 日志对象(建议前缀 [Plugin:<id>]) |
ctx.registerIpc(fn) |
方法 | 注册全局 IPC 通道(包裹错误兜底) |
ctx.registerInvokeHandlers(map) |
方法 | 注册命名方法,渲染端旧式 api.plugins.invoke(id, method, payload) 调用 |
ctx.registerProjectInitializer({ scenario, fn }) |
方法 | 注册新项目创建钩子 |
ctx.registerAgentTools(tools) |
方法 | 注册 Agent 工具 |
ctx.registerClipRoute(routes) |
方法 | 注册剪贴板路由(进阶) |
⚠️ 这些方法不是 Cordis 原生 API,是宿主
plugin-manager.cjs在rootContext.plugin((ctx) => {...})里给每个子上下文挂的。你之所以能在apply(ctx)里直接调用,是因为宿主在调你的apply之前塞了进去。不要在代码里假设它们来自 Cordis,也不要指望其它 Cordis 插件能直接访问(它们带pluginId归属,写进共享registry,卸载时按 owner 回收)。
3. 目录与源码 / 产物位置
3.1 真实目录结构
wegirl-office/ ← 插件工作区(外部目录,不在宿主仓库内)
├── <id>/
│ ├── plugin.json # 清单(必需)
│ ├── main.cjs # 主进程入口(可选;需 IPC / 本地存储 / Agent 工具时写)
│ └── renderer/
│ ├── index.jsx # 渲染端总入口(具名导出所有组件,必需)
│ ├── components/ # UI 组件
│ └── *.module.css # CSS Modules(自动产出 renderer.css)
│ ├── skills/ # 技能模板(可选,原样复制到产物)
│ └── assets/ # 静态资源(可选)
└── dist/
└── <id>/ # buildPlugin 的输出(运行时加载的是这里)
├── plugin.json # 产物清单(自动补 style / web / webStyle / build 字段)
├── main.cjs # 主进程产物
├── renderer.mjs # 桌面渲染端产物(Electron,wegirl-plugin:// 懒加载)
├── renderer.css
└── web/ # supportsWeb 时生成的网页产物
├── index.mjs # 网页渲染端产物
└── index.css
宿主自动扫描加载(2026-09-14 起,无软链):
- local:插件开发场景项目根下的
dist/<id>/直读(优先级最高;每次扫描前自动刷新项目列表)。 - user:zip 安装的插件解包在
~/.wegirl-office/plugins/<id>。 - builtin:随包内置(宿主
electron/plugins/)。 - 同 id 优先级:local > user > builtin(后扫描覆盖先扫描)。
约定:
- 渲染端必须是 ESM;React / ReactDOM 由宿主共享,不要打包进插件(否则两棵 React 树互不兼容,Hooks 报错)。
- 不要
import宿主源码(@/路径);一切宿主能力走@wegirl/sdk(渲染端)/ctx.*(主进程)。 - 工作区根可用环境变量
WEGIRL_PLUGINS_HOME覆盖(默认/Users/tiger/wegirl-office/plugins)。
4. 构建、链接与调试命令
插件有两种构建 / 安装方式。第三方开发者一律走 4.1(软件内「插件开发」工作台);plugins/scripts/build.mjs 等 npm 脚本只存在于宿主仓库内部、随仓库分发,不会随安装包 / 插件源码提供给外部开发者,不要把它写进对外的构建指引。
4.1 推荐:插件开发工作台(plugin-dev,所有开发者通用)
这是官方唯一对外的构建 / 链接入口——编译能力随安装包分发(esbuild 已 asar 解包),第三方开发者不需要拉宿主仓库、不需要自己装 Node / esbuild、不需要 plugins/scripts/build.mjs。
- 在软件内打开「插件开发」场景项目(
plugin-devscenario)。右侧出现「插件」tab,里面是该工作台。 - 该项目根(即
project.path)就是你的插件工作区根:根下每个含plugin.json的子目录 = 一个插件(与仓库WEGIRL_PLUGINS_HOME约定一致)。 - 在列表选中插件,点 「编译并安装」 按钮 —— 它一次性完成:
- 内联构建:主进程直接调 esbuild 编译(不 spawn 系统 node、不调仓库
build.mjs),产物落<项目根>/dist/<id>; - 自动加载:宿主自动扫描插件开发项目的
dist/<id>(本地开发直读,无需任何软链 / 安装步骤); - 热重载:触发宿主
PluginManager.reload,主进程 + 渲染端立即生效,无需重启 Electron。
- 内联构建:主进程直接调 esbuild 编译(不 spawn 系统 node、不调仓库
- 改完源码 → 再点一次「编译并安装」即生效。只想刷新已装插件(不再构建)用 「热重载」 按钮(主进程 + 渲染端立即生效,UI 改动必要时会重载窗口)。
- 「打包」 按钮:把
dist/<id>打成 zip 落到<项目根>/staging/<id>-<version>.zip(版本号自动 +1),用于发商店 / 分发。 - 脚手架:在列表里填
id/name一键生成新插件骨架(plugin.json+main.cjs+renderer),免去手写目录结构。
构建进度与错误会推到主进程 console 与构建日志区;构建失败看 desktop:pluginDev:buildLog。
未指定工作区根时,构建兜底到外部默认目录(
WEGIRL_PLUGINS_HOME,默认/Users/tiger/wegirl-office/plugins),产物落<home>/dist/<id>;该兜底仅作「不在插件开发项目里时」的备用路径。
4.2 仅宿主仓库内部可选:npm 脚本(core 开发者)
只有同时持有 wegirl-dashboard 仓库的 WeGirlOffice 核心开发者,才能用仓库里的便捷脚本(plugins/scripts/build.mjs、install-local.mjs),它们不随安装包提供、外部开发者无此文件:
# 仓库内:构建单个插件(不压缩 + sourcemap)→ wegirl-office/plugins/dist/<id>
npm run plugin:build <id>
# 加载由宿主自动扫描完成(本地开发项目 dist 直读,local 优先级最高),无需软链 / 安装步骤
核心开发者的日常开发也可以用 4.1 的工作台(只要把插件工作区根指向仓库 WEGIRL_PLUGINS_HOME 即可),二者产物完全一致;区别只是入口在「软件内」还是「命令行」。
4.3 铁律与调试
⚠️ 铁律:改了源码必须重新构建 + 重装(软件内点「编译并安装」或「热重载」)。运行时加载的是
dist产物,不是wegirl-office/plugins/<id>源码——这是插件开发最高频的「改了没生效」原因。改main.cjs后必须走热重载(或重启 Electron),因为主进程有require缓存。
调试:
- DevTools → Sources 搜
wegirl-plugin://<id>/renderer.mjs看插件源码。 - 组件加载失败会渲染降级提示并输出
console.error('[plugin] 组件未找到' ...),列出可用导出。 console.warn('[plugin] 缺少 component 名')说明清单component没配对。- 主进程日志前缀统一
[Plugin:<id>],构建失败看主进程 console 与「插件」tab 的构建日志区。
5. plugin.json 清单字段
{
"id": "my-plugin",
"name": "我的插件",
"version": "1.0.0",
"description": "一句话说明",
"engine": "wegirl-plugin@1",
"category": "scene",
"scenario": "social-media",
"projectType": "work",
"icon": "🎨",
"builtin": false,
"dependencies": ["knowledge"],
"main": "main.cjs",
"renderer": "renderer.mjs",
"supportsWeb": true,
"supportsCloudProject": true,
"contributes": {
"panels": [],
"fileEditors": [],
"modes": [],
"skills": []
}
}
| 字段 | 必填 | 说明 |
|---|---|---|
id |
是 | 插件唯一标识,只用字母、数字、-、_。 |
name |
是 | 显示名称。 |
version |
是 | 语义化版本,用于缓存失效。 |
engine |
是 | 固定 wegirl-plugin@1。 |
category |
否 | scene / agent-tool / agent-node,默认 scene。 |
scenario |
否 | 所属场景。contributes 子项继承此值,不再单独声明。 |
projectType |
否 | code(代码开发)/ work(日常办公),决定创建项目时的分组。 |
icon |
否 | 建议 emoji。 |
builtin |
否 | true 时构建产物自动镜像到 electron/plugins/<id>/。 |
dependencies |
否 | 依赖的其他插件 id(按拓扑顺序先激活被依赖者)。 |
main |
否 | 主进程入口文件名,默认 main.cjs。产物由 buildPlugin 生成,无需手填。 |
renderer |
否 | 桌面渲染端产物文件名,默认 renderer.mjs。 |
supportsWeb |
否 | false 表示网页 / 云端上下文过滤掉该插件(桌面专属能力时用)。true 时 buildPlugin 自动生成 web/index.mjs + web/index.css,产物清单追加 web / webStyle。 |
supportsCloudProject |
否 | true 表示云端(网关直建)项目可选中该场景。 |
contributes |
否 | 界面功能注册入口:panels / fileEditors / modes / skills。 |
源码
plugin.json只需写id/name/version/engine/category/scenario/main/renderer/supportsWeb/contributes。style/web/webStyle/build由 buildPlugin 在dist产物清单里自动补齐,不要手填。
6. 贡献类型(contributes)
6.1 panels(面板)
所有界面入口都写在 contributes.panels 里,用 mountPoint 指明出现在哪里。mountPoint 五选一:editorTab / centerPanel / rightPanel / fileTree / centerOverlay。
每个面板通用字段:
| 字段 | 必填 | 说明 |
|---|---|---|
id |
是 | 面板唯一标识,同时作为该面板在宿主的 tab / 会话 id。全局唯一,不要与内置 chat / feed / file:... / whatsapp:web 撞车。 |
title |
是 | 显示文本(中文名)。 |
icon |
否 | emoji 图标。 |
component |
是 | 渲染端导出的组件名(必须在 renderer/index.jsx 具名导出)。 |
mountPoint |
是 | 见下方五类。 |
priority |
否 | 排序,数字越大越靠前。 |
rightTab |
否 | 仅 editorTab:点击该面板时右侧自动切到的 tab id(如 browse)。 |
tabPrefix |
否 | 仅 centerPanel:前缀匹配标识(如 email:),用于右侧条目打开中央面板(见第 9 节)。 |
6.1.1 editorTab(中间面板 + 项目树 + 顶部 tab)
最常用。面板既出现在左侧项目树虚拟节点,也出现在顶部 tab 栏;点击任一处都会激活它。
{ "contributes": { "panels": [
{ "id": "__product_library__", "title": "产品库", "icon": "📦", "component": "ProductLibraryPanel", "mountPoint": "editorTab" }
] } }
适用:需要常驻入口的工作台(产品库、素材库、编排画布)。
6.1.2 centerPanel(中间面板,无侧边入口)
同样在中间区域渲染,但不会生成项目树节点、也不会进入顶部 tab 栏。适合「列表点击后展开」的动态内容(邮件详情、聊天详情)。
{ "contributes": { "panels": [
{ "id": "email", "title": "邮件详情", "component": "EmailDetail", "mountPoint": "centerPanel", "tabPrefix": "email:" }
] } }
6.1.3 rightPanel(右侧 tab)
作为独立 tab 出现在右侧边栏(位于「浏览」「查找」之间),常用于场景专属工具。
{ "contributes": { "panels": [
{ "id": "prototype_manager", "title": "原型管理", "icon": "🎨", "component": "PrototypeManagerPanel", "mountPoint": "rightPanel" }
] } }
6.1.4 fileTree(替换文件树)
用自定义列表替换默认项目文件树,常用于邮件、WhatsApp 等不需要普通文件树的项目。
{ "contributes": { "panels": [
{ "id": "email_list", "title": "邮件列表", "icon": "✉️", "component": "EmailList", "mountPoint": "fileTree" }
] } }
6.1.5 centerOverlay(覆盖层)
常驻覆盖层(全局浮窗),按需使用。
{ "contributes": { "panels": [
{ "id": "my_overlay", "title": "浮层", "component": "MyOverlay", "mountPoint": "centerOverlay" }
] } }
6.2 fileEditors(文件编辑器)
把特定文件类型映射到编辑器组件。宿主打开匹配文件时自动选用,无需虚拟会话。
{ "contributes": { "fileEditors": [
{ "id": "prototype-page", "title": "原型", "icon": "🎨", "match": "\\.prototype/[^/]+/.*\\.prototype\\.json$", "component": "PrototypeCanvasPanel", "priority": 80 },
{ "pattern": "*.{png,jpg,jpeg,gif}", "component": "DesktopImagePreview", "title": "图片", "icon": "🖼️" }
] } }
匹配方式(三选一):pattern / patterns(glob,匹配文件名);match(正则,匹配完整路径)。priority 越大越优先;同优先级按清单顺序。priority > 0 即可覆盖内置兜底编辑器。
6.3 modes(顶层模式,按需懒加载)
contributes.modes 声明一个顶层模式——宿主的模式选择器从硬编码 office 扩展为「内置 office + 插件 modes」。切到该模式时,宿主通过 RegisteredPluginMode(React.lazy + Suspense)懒加载插件渲染端产物,整份插件(包括其重型依赖,如 Phaser)只在进入时下载。
{ "contributes": { "modes": [
{ "id": "sandbox", "label": "沙盘", "icon": "🎮", "component": "SandboxDashboard" }
] } }
字段:
| 字段 | 必填 | 说明 |
|---|---|---|
id |
是 | 模式标识。宿主 App 的 mode 状态等于此值(如 mode === 'sandbox')时渲染该组件。 |
label |
否 | 模式选择器的显示名,默认用 id。 |
icon |
否 | 模式选择器的图标。 |
component |
是 | 渲染端导出的组件名(必须在 renderer/index.jsx 具名导出)。宿主 App 把以下标准 props 注入该组件:humanPlayerId / onLogout / onSwitchMode / onSwitchOrg / onUpgrade。 |
宿主路由(见 src/App.jsx):
{mode === 'office' ? (
<OfficeDashboard humanPlayerId={humanPlayerId} onLogout={handleLogout} onSwitchMode={handleSwitchMode} onSwitchOrg={onSwitchOrg} onUpgrade={onUpgrade} />
) : mode ? (
<RegisteredPluginMode modeId={mode} humanPlayerId={humanPlayerId} onLogout={handleLogout} onSwitchMode={handleSwitchMode} onSwitchOrg={onSwitchOrg} onUpgrade={onUpgrade} />
) : null}
适用场景:重型依赖(Phaser / Three.js / 大型引擎)不应进主包,作为 mode 插件按需加载。沙盘(sandbox)即此模式——迁移后
vendor-phaser从主包剥离为 0.05 KB 空 chunk,仅在进入沙盘时随插件下载。
6.4 skills(技能,manifest 声明)
技能不走 ctx 注册方法,而是写在 contributes.skills,宿主在创建项目时把 skills/<name>/SKILL.md 复制到项目 .agents/skills/<name>:
{ "contributes": { "skills": [
{ "name": "my-skill", "scenario": "knowledge" }
] } }
无 scenario 时对所有场景生效;带 scenario 时仅复制到该场景的新项目。
7. 渲染端组件开发
7.1 入口与导出
esbuild 自动查找 renderer/index.jsx|index.js|index.tsx,以具名导出暴露组件:
export { ProductLibraryPanel } from './components/ProductLibraryPanel';
export { MediaLibraryPanel } from './components/MediaLibraryPanel';
7.2 组件约定
- 使用函数组件 + Hooks。
- 不要
import 'react'/'react-dom'(宿主共享,已外部化;自己打包会两棵 React 树互不兼容)。 - 不要
import宿主源码;宿主能力走@wegirl/sdk(第 8 节)。 - 可用 CSS Modules(
*.module.css),宿主自动注入样式。 - 资源路径:插件产物里相对路径
.会 404,需改为站点根绝对路径(如/tilesets、/maps,网页 / 桌面同源public均挂根)。
7.3 组件能拿到的 props
| props | 说明 |
|---|---|
project |
当前项目对象 |
session |
当前会话(含虚拟会话) |
theme |
'dark' / 'light' |
currentUserId / contactList |
当前用户 / 联系人 |
onOpenFile / onOpenPanelItem / onClosePanelItem |
宿主注入回调(见第 9、10 节) |
onInsertTextToChat(payload, opts?) |
官方「插入到会话」通道(发给 AI 的唯一正规入口),详见 7.5 |
onSelectSession |
切换会话 |
panelItem |
由右侧条目触发时的整体对象(含 payload) |
file |
文件编辑器模式下,当前文件对象(file.path / file.content) |
chatProps |
聊天相关 props(已剥离 onSendMessage / onRetryMessage / onCancel,见 7.5 说明) |
humanPlayerId / onLogout / onSwitchMode / onSwitchOrg / onUpgrade |
仅 modes 顶层模式组件接收(宿主 App 注入) |
7.4 复用宿主组件
宿主把内置组件注册到 @wegirl/sdk(sdk.editors / sdk.panels 活 getter),插件经 sdk 读取:
import sdk from '@wegirl/sdk';
const WikiGraphPanel = sdk.panels.WikiGraphPanel;
export function WikiPanelWrapper (props) {
return WikiGraphPanel ? <WikiGraphPanel {...props} /> : <div>未加载</div>;
}
sdk.editors:文件编辑器类组件(EditorShell / DesktopImagePreview / DesktopVideoPreview / DesktopPdfPreview / DesktopFileEditor / AgentGraphEditor / AgentGraphPanel)。sdk.panels:通用面板类组件。
7.5 插入到会话:onInsertTextToChat(发给 AI 官方通道)
面板组件(editorTab / centerPanel / 虚拟会话面板)与文件编辑器(editorProps.onInsertTextToChat)都会收到宿主注入的 onInsertTextToChat。这是插件把内容「交给 AI」的唯一正规通道——落点逻辑(当前会话优先、伪会话回落最后工作会话、跳转聊天 tab)全部由宿主维护,宿主改行为插件自动跟随。
export function MyPanel ({ onInsertTextToChat }) {
const send = (e) => {
onInsertTextToChat({
text: '请审查这份草稿',
references: [{ type: 'clip', path: 'data/draft.json', content: '...' }]
}, e?.shiftKey); // 第二参数透传 Shift:弹「发送到会话」选择器
};
return <button onClick={send}>发给 AI</button>;
}
payload 三种形态:
| 形态 | 行为 |
|---|---|
'纯文本字符串' |
追加到目标会话输入框 |
{ type: 'mention' | 'clip', ... } |
单条结构化引用 chip(挂到输入框引用区) |
{ text, references?: [...] } |
复合形态(推荐):references 逐条挂 chip,text 追加输入框 |
第二参数 opts:true 或 { shiftKey: true } → 不落当前会话,弹宿主「发送到会话」选择器,由用户挑目标会话(选完同样只进输入框,不自动发送)。
保证的行为(宿主实现,插件无需关心):
- 永不直接发送——只写入输入框 + 挂引用 chip + 跳转聊天 tab,由用户确认后发送;
- 落点:当前选中的真实会话优先;正在看文件 tab 等伪会话(
taskType === -1)时回落本项目最后工作会话;找不到落点会话时自动退回会话选择器,内容不丢。
⚠️ 不要复刻落点逻辑:旧做法是在插件里 import
actions.appendInputForSession/getDesktopDashboardState()自己算目标会话(如 static-site 早期aiSend.js约 30 行复刻代码)——宿主调整落点规则后插件会悄悄失配。一律走onInsertTextToChat。⚠️
chatProps里没有发送类回调:宿主传给插件面板的chatProps已剥离onSendMessage/onRetryMessage/onCancel,插件无法也不应该直接触发发送;需要「用户确认后发送」的语义天然由本通道保证。通道无返回值。若需要区分「已插入 / 弹了选择器」给用户提示,用你传入的
shiftKey自行推断:传了 shift 即弹了选择器,否则已插入当前会话。
sessionTransfer.open(...)(8.2.6)仍可用于自定义转发流程;普通的「发给 AI」场景优先用 onInsertTextToChat。
8. 调用宿主能力:@wegirl/sdk 与 cap
插件只能看到宿主通过 SDK / cap 暴露的能力,不要直接 import 宿主 store 源码。
8.1 cap 客户端(主进程能力调用)
渲染端调用主进程 cap 一律经 @wegirl/sdk(唯一通道):
// ① getCapability / useCapability:能力方法代理(Electron / 网页通用)
// 原样返回信封 { success, ... },不自动解包、不抛错,调用方自行判断 res.success
import { getCapability, useCapability } from '@wegirl/sdk';
const myPlugin = getCapability('myPlugin'); // 组件内可用 const myPlugin = useCapability('myPlugin');
const res = await myPlugin.ping({ a: 1 });
if (!res.success) throw new Error(res.error);
// ② callCap(SDK 内置,推荐):已封装「解包信封 + 失败抛错」,业务代码直接拿数据
import { callCap } from '@wegirl/sdk';
const tabs = await callCap('knowledge', 'wikiIngestGetTabs', projectPath); // 成功返回 data / 失败抛 Error
⚠️ 两种返回值口径(务必分清):
getCapability('x').method(...)不解包:原样返回{ success, ... }信封、失败也返回信封(不抛错)。用if (!res.success)判断。callCap(SDK 内置)已解包信封:成功返回data(数组/标量)或整对象,失败抛Error。用try/catch捕获。 插件内二选一并保持一致,避免同一能力两种口径混用。
⚠️ cap 调用约定(高频坑):宿主能力方法把整个 payload 作为单个对象参数传入(
impl.method(payload),不是method(a, b, c))。因此主进程ctx.cap.define的 impl 方法必须用单对象解构签名:// ✅ 正确:单对象解构 ping: async ({ a, b } = {}) => ({ echo: { a, b } }), // ❌ 错误:位置参数(a 永远是 undefined,内部校验会恒报「a 必填」) ping: async (a, b) => ({ echo: { a, b } }),需要向调用窗口推送进度的方法,把
event声明为最后一个形参(宿主作为尾参透传ipcMain的 event):runWithProgress: async (taskId, event) => { event.sender.send('myPlugin:progress', {...}); }。
callCap 已内置于 @wegirl/sdk(宿主 hostSdk.js 实现并解包信封),插件不再需要在 renderer/api.js 里自建同名函数;历史插件里自建的 callCap / onCap 可保留为转发,也可直接改用 SDK 导出:
import { callCap, events } from '@wegirl/sdk';
// 订阅主进程原生推送通道(webContents.send 的通道),返回解绑函数。
export const onCap = (channel, cb) => events.on(channel, cb);
8.2 @wegirl/sdk 全量导出(宿主 API 参考)
★ 唯一通道铁律(2026-09-14,详见
WEGIRL_SDK_SPEC.md):插件源码访问宿主能力只允许经@wegirl/sdk导入,推荐默认导入import sdk from '@wegirl/sdk'(直通运行时单例,永不怕 shim 导出面滞后)。禁止直接使用window.__WEGIRL__(宿主内部交接点)与window.api(原生桥);editors / panels 也已挂到sdk.editors/sdk.panels。宿主构建期守卫会扫描插件源码,命中即告警/报错。旧的「运行时兜底globalThis.__WEGIRL__?.sdk」写法已废除,遇旧编译器缺导出请升级编译器包。
⚠️ 导入契约:shim 具名导出为构建期快照(见 8.2.1 清单)。已知文档字段 → 具名导入;其余一律默认导入后
sdk.xxx。真相源:宿主src/plugins/hostSdk.js(createHostSdk)+electron/plugins/build-lib.cjs(SHIMS)。本节与两者同步维护。
8.2.1 具名导出清单(shim 实际导出的字段)
| 导出名 | 类型 | 说明 |
|---|---|---|
version |
string | SDK 版本号 |
fs / ui / media / store |
object | 四大能力域,见 8.2.3 / 8.2.4 / 8.2.6 / 8.4 |
invoke |
fn | invoke(pluginId, method, payload),见 8.2.2 |
logger |
object | log/warn/error(自动加 [plugin] 前缀) |
apiClient |
object | 网关请求适配层,见 8.2.2 |
toFileUrl |
fn | 本地路径 → 可渲染 URL |
dataUrlToFile / uploadMedia |
fn | 媒体上传工具,见 8.2.6 |
parsePsdFile |
fn | PSD 设计稿解析(宿主实现,别打进插件 chunk) |
ForwardModal |
组件 | 转发面板(宿主 React 实例) |
sessionTransfer |
object | 会话转发,仅需 .open |
useDesktopDashboardStore / getDesktopDashboardState / actions |
— | 全局状态(同 store.*,见 8.4) |
getCurrentProject / useCurrentProject |
fn | 当前项目派生 getter(快照 / 响应式 hook,宿主内建本地 find + 云端回退,见 8.4) |
useModal / showConfirmModal |
fn | 弹窗(同 ui.*) |
whatsapp / app |
object | WhatsApp 能力面 / app.openImageViewer |
MessageList / EditorShell / AIButton |
组件 | 宿主组件实例,见 8.2.5 |
formatTime |
fn | 时间格式化 |
isImageMedia / isVideoMedia / findImageViewerIndex |
fn | 媒体类型判断 |
sanitizeHtml |
fn | DOMPurify 消毒(避免插件自带双份 DOMPurify) |
icons |
object | 宿主图标组件(ReplyIcon / MailIcon / WhatsAppIcon 等) |
isElectron |
fn | 环境判断 |
openFile |
fn | 用系统默认程序打开文件 |
getCapability / useCapability / getCapabilityRegistry |
fn | cap 能力客户端,见 8.1 |
callCap |
fn | cap 瘦封装(SDK 内置):解包信封 + 失败抛错,见 8.1 |
harness |
object | harness.listNodes() / harness.listTools()(自动解包,返回数组;DSL 自定义节点/工具清单) |
registerOpener / callOpener |
fn | 插件 opener 注册表(项目树点击 → 插件自带打开逻辑) |
editors / panels |
object | 宿主编辑器/面板组件注册表(活 getter;如 editors.EditorShell、editors.DesktopImagePreview) |
仅默认导入可见(shim 未具名导出):wegirlFetch / apiBase / reloadPlugin / pluginsStore / pluginsCloudStore / aiRunStore / media 之外的工具函数等。
8.2.2 网络调用(访问云端接口,带宿主鉴权)
宿主自动注入 Authorization: Bearer <token> + 多租户头 x-org-domain,插件自身不接触任何凭证,三种方式按需选:
// ① wegirlFetch:原始网关 fetch(返回原生 Response),适合直接打 REST 接口
import sdk from '@wegirl/sdk';
const res = await sdk.wegirlFetch(`${sdk.apiBase}/plm/products/query?page=1&limit=20`, {
method: 'GET',
skipErrorModal: true // 失败不弹宿主全局错误框,由插件内联展示
});
const data = await res.json();
// sdk.apiBase 即网关基地址(测试 wx.api.microsoul.com / 正式 api.weiniuai.com)
// ② apiClient:模块化网关请求(自动拼模块 URL + 解包统一响应 R{code,data,message})
import { apiClient } from '@wegirl/sdk';
// 成功口径:code === 200 → 直接返回 data;否则抛 Error(e.message = 后端 message)
const list = await apiClient.get('contacts', '/user/list', { query: { page: 1 } });
await apiClient.post('ai', '/some/path', { body: { foo: 1 } }); // JSON body
await apiClient.postForm('uaa', '/oauth/token', { body: {...} }); // 表单
// 模块名:contacts | hr | ai | group | accounts | uaa | im;opts: { query, body, headers, raw, form, skipErrorModal, skipOrgDomain }
// ③ sdk.invoke:调用插件自己的主进程/后端方法(桌面 IPC 与云端代理自动路由)
const data = await sdk.invoke('myPlugin', 'queryXxx', { keyword: 'a' });
// 路由规则:当前是云端项目 → POST {apiBase}/plugins/{id}/invoke(后端实现);否则走桌面 IPC(main.cjs)。
// 返回约定:成功解包、失败抛错(与 8.1 callCap 一致)。
踩坑:
wegirlFetch返回的是原生 Response(不解析 JSON、不抛业务错);apiClient才做 R 解包与抛错。要 401 自动登出、统一错误弹窗就用apiClient;要自由控制就用wegirlFetch+skipErrorModal: true。
8.2.3 文件能力(fs / openFile)
import { fs, openFile } from '@wegirl/sdk';
await fs.readFile(path); // → 文本内容
await fs.readFileBase64(path); // → base64(图片等二进制)
await fs.writeFile(path, content); // 写文本
await fs.writeFileBase64(path, b64); // 写 base64
await fs.exists(path); await fs.remove(path);
fs.raw.xxx // 兜底:SDK 未覆盖的宿主桌面文件能力(原始句柄)
await openFile(path); // 系统默认程序打开
8.2.4 UI 能力(弹窗 / 宿主组件)
import { ui, useModal, showConfirmModal, ForwardModal, MessageList, EditorShell, AIButton, icons } from '@wegirl/sdk';
// ui.useModal / ui.showModal / ui.showConfirmModal —— 统一弹窗(showConfirmModal 返回 Promise<boolean>)
// ForwardModal —— 转发面板;MessageList —— 聊天气泡列表;EditorShell —— 文件编辑器统一外壳(原始内容+保存状态)
// AIButton —— AI 行动按钮;icons —— 宿主图标(保持视觉一致)
// @wegirl/ui 另导出:DraggableModal / ModalThemeWrapper / useModal / WegirlAgentModelSettingsModal
8.2.5 媒体能力
import { media, toFileUrl, dataUrlToFile, uploadMedia, parsePsdFile, isImageMedia, isVideoMedia } from '@wegirl/sdk';
// media = MediaMessage/utils + MediaService 平铺(图片/视频工具 + 上传服务)
// toFileUrl(path):本地路径 → <img>/<video> 可用 URL;parsePsdFile(file):PSD → 图层
8.2.6 其它
sdk.reloadPlugin(pluginId, opts?):编译产物后热重载插件(不重启应用;web 端不支持)。sdk.pluginsStore/sdk.pluginsCloudStore:插件商店 API(list/install/uninstall/checkUpdates/onChanged...;前者 Electron 本地安装、后者始终走云端目录)。sdk.sessionTransfer.open(...):会话转发(弹「发送到会话」选择器)。普通的「发给 AI」场景优先用面板 props 的onInsertTextToChat(见 7.5,含落点逻辑);仅自定义转发流程才直接用本接口。sdk.whatsapp.*:WhatsApp 能力面(cap 方法走getCapability('whatsapp'))。sdk.app.openImageViewer(payload):宿主图片查看器。sdk.harness.listNodes()/listTools():DSL 自定义节点/工具清单(编排编辑器调色板)。sdk.registerOpener(pluginId, name, fn)/callOpener(pluginId, name, ...args):注册/调用插件自带的项目打开逻辑。
8.3 文件与中央面板(最常用)
| 方法 | 行为 | 是否激活中央 |
|---|---|---|
actions.openAndLoadFile(projectId, node) |
规范文件打开入口:登记 tab + 读内容 + 激活。node = { path, name, content?, type? }。 |
✅ |
actions.openEditorItemForProject(projectId, node) |
编辑器条目(宿主不读盘):登记 tab + 激活,内容由编辑器按 file.params 自取。node = { id, name, ...自定义参数 }(name 用于 fileEditors 匹配,详见 10.3 入口 B)。 |
✅ |
actions.closeFileForProject(projectId, path) |
关闭文件 tab,自动切到下一个。 | ✅ |
actions.openPanelItemForProject(projectId, item) |
通用右侧条目 → 中央(见第 9 节)。item = { id, tabId?, tabPrefix?, title, icon?, payload? }。 |
✅ |
actions.closePanelItemForProject(projectId, itemId) |
关闭 panelItem 中央 tab,自动切激活态。 | ✅ |
actions.updatePanelItemPayloadForProject(projectId, itemId, payload) |
增量更新某 panelItem 的 payload。 | — |
踩坑点:不要用底层原语
openFileForProject(只登记 tab、不激活)。文件打开后中央不切换,换成actions.openAndLoadFile。
8.4 其它常用动作 / 状态
- 会话 / 消息:
actions.createTransientSession、actions.updateSession、actions.setMessagesForSession、actions.completeTodoForSession、actions.compressSession等。 - 编排:
actions.loadOrchestrations、actions.saveOrchestration、actions.deleteOrchestration、actions.loadCloudOrchestrations。 - 输入框 / 引用:
actions.appendInputForSession、actions.addReferenceForSession(一般不直接用——「发给 AI」统一走面板 props 的onInsertTextToChat,见 7.5)。 - 右侧面板:
actions.setRightPanelTabForProject(projectId, tab)(切换右侧 tab)。 - 视图状态:
actions.setEditorActiveTabForProject、actions.updateFileContentForProject、actions.setSearchStateForProject。 - 读取状态(组件内用 Hook):
const openFiles = useDesktopDashboardStore(s => s.openFilesByProject[projectId] || []); - 读取快照(事件 / 回调里用):
const state = getDesktopDashboardState(); - 当前项目首选专用 getter:
getCurrentProject()(快照)/useCurrentProject()(响应式)——宿主已封装「本地 find + 云端 selectedCloudProject 回退」,不要手写这段选择器。
实际可用方法以
@wegirl/sdk导出的actions为准。
9. 打开中央面板:panelItem 接口
目标:让右侧面板(或任何列表)里的可点击条目,像「点击文件打开编辑器」一样,在中央工作区激活一个 tab——无需在宿主写特例分支。
邮件、WhatsApp 聊天、插件列表项都已用这套接口;条目本质是文件的(原型 / 知识库)走 8.3 的
openAndLoadFile即可,不需要本接口。面板 / 编辑器之间的完整互调矩阵(Panel↔Panel、Panel↔Editor、→ 会话)见第 10 节。
9.1 怎么用
右侧面板组件通过宿主注入的 onOpenPanelItem(item) 回调触发:
onOpenPanelItem({
id: 'email-' + mail.id, // 唯一标识(建议全局唯一)
tabId: 'email:' + mail.id, // 中间 tab id(省略时默认 panel:<id>)
tabPrefix: 'email:', // 前缀匹配(与某个 centerPanel 面板的 tabPrefix 对应)
title: mail.subject, // 顶部 tab 文案
icon: '✉️',
payload: { email: mail } // 任意透传数据,会展开为组件 props
});
宿主会:登记 tab → 激活中央面板 → 按 tabId / tabPrefix 在插件面板里查找并渲染对应组件。组件里通过 payload 展开字段或 panelItem 整体对象拿到数据。
9.2 item 形状
| 字段 | 必填 | 说明 |
|---|---|---|
id |
是 | 面板项唯一标识,同时作为持久键与 selectedSession.id(前缀 panel:)。 |
tabId |
否 | 中间 tab id。想命中某插件的面板时,设为该面板的 id 或配合 tabPrefix。 |
tabPrefix |
否 | 前缀匹配标识(如 email: / whatsapp:chat:);宿主按此前缀命中对应面板。 |
title |
推荐 | 顶部 tab 文案。 |
icon |
否 | tab 图标。 |
payload |
否 | 透传给渲染组件的数据;命中后展开为 props,并保留 panelItem 整体对象。 |
9.3 命中规则
宿主按 centerPanel → editorTab → centerOverlay 顺序,在插件 panels 里用 tabId 精确匹配或 tabPrefix 前缀匹配。只要你的面板上声明了对应的 id 或 tabPrefix 即可,无需在宿主写判断。
9.4 组件拿到什么
命中后组件收到:payload 的每个字段(平铺)+ panelItem 整体对象 + 通用上下文(project / theme / onClosePanelItem 等)。
// 命中 { id:'email_detail', mountPoint:'centerPanel', tabPrefix:'email:' }
export function EmailDetail ({ email, panelItem, theme, onClosePanelItem }) {
const mail = email || panelItem?.payload?.email;
return (
<div>
<button onClick={() => onClosePanelItem?.(panelItem?.id)}>关闭</button>
<h3>{mail?.subject}</h3>
</div>
);
}
9.5 右侧面板自动获得的回调
右侧面板(rightPanel / fileTree 渲染端)通过 common 对象自动收到:
| 回调 | 用途 |
|---|---|
onOpenFile(node) |
打开文件到中央(等价于 openAndLoadFile)。 |
onOpenPanelItem(item) |
打开一个通用 panelItem 到中央(见上)。 |
onClosePanelItem(itemId) |
关闭某 panelItem 中央 tab。 |
优先用这些回调,不要自己拼
setSelectedSession——激活态由宿主统一管理。
10. 组件互调指南:Panel / Editor / 会话
面板(panels)、文件编辑器(fileEditors)、顶层模式组件之间如何互相跳转、传数据。核心原则:组件之间不直接 import、不直接持有对方实例,一切互调经宿主中转——要么「打开文件 / panelItem 让宿主路由」,要么「走宿主注入回调 / actions」。
10.1 调用矩阵
| 调用方向 | 通道 | 关键 API |
|---|---|---|
| Panel → Panel | panelItem 路由 | onOpenPanelItem / actions.openPanelItemForProject(见 9.1) |
| Panel → Editor | 打开文件 / 编辑器条目(按 pattern 路由到 fileEditor) | 本地文件:actions.openAndLoadFile / onOpenFile(见 8.3);编辑器自取内容:actions.openEditorItemForProject(见 10.3 入口 B) |
| Editor → Panel | 同 Panel → Panel(Editor 收同一组回调) | onOpenPanelItem / actions.openPanelItemForProject |
| Editor → Editor | 打开另一个文件 | onOpenFile / actions.openAndLoadFile |
| Panel / Editor → 会话(AI) | 插入到会话 | onInsertTextToChat(见 7.5) |
10.2 Panel → Panel
目标面板先在清单里声明可命中标识(centerPanel 配 tabPrefix,或 editorTab 直接用面板 id):
{ "contributes": { "panels": [
{ "id": "email", "title": "邮件详情", "component": "EmailDetail", "mountPoint": "centerPanel", "tabPrefix": "email:" }
] } }
调用方(任意面板组件)用宿主注入的 onOpenPanelItem 触发:
onOpenPanelItem({
id: 'email-' + mail.id, // panelItem 唯一键
tabId: 'email:' + mail.id, // 与目标面板 tabPrefix 前缀匹配
title: mail.subject,
icon: '✉️',
payload: { email: mail } // 透传数据,展开为目标组件 props
});
- 命中规则见 9.3;目标组件拿到
payload平铺字段 +panelItem整体对象(见 9.4)。 - 已打开的面板:
actions.updatePanelItemPayloadForProject(projectId, itemId, payload)增量刷新数据;actions.closePanelItemForProject(projectId, itemId)关闭(见 8.3)。 - 跨插件同样适用:宿主命中时扫描的是所有插件的面板声明,只要对方声明了对应
id/tabPrefix即可命中——不需要、也不应该 import 对方组件。想复用宿主内置组件用sdk.panels/sdk.editors(见 7.4)。
10.3 Panel → Editor
不存在「直接调用编辑器组件」的通道。把文件交给宿主,宿主按 contributes.fileEditors 的 pattern / match + priority 自动选出编辑器渲染(见 6.2)。按内容来源分两条入口:
入口 A:本地文件(宿主读盘)
// 规范入口:登记 tab + 宿主读内容 + 激活中央(node = { path, name, content?, type? })
actions.openAndLoadFile(project.id, { path: project.path + '/data/x.prototype.json', name: 'x.prototype.json' });
// 右侧面板 / 文件树组件可直接用注入回调(等价语义)
onOpenFile({ path, name });
入口 B:编辑器条目(编辑器自取内容,宿主不读盘)
内容不在本地文件系统(云端 / 数据库 / 派生数据)时,用 openEditorItemForProject——只给 id + 标准 name,宿主按 name 命中 fileEditors 声明并渲染编辑器,如何取内容由编辑器自己决定(cap / apiClient / 本地存储均可):
// node = { id, name, ...自定义参数 }:id 是调用方自定义的稳定键(去重 + 再激活),
// name 是标准文件名形态(如 'x.prototype.json'),仅用于 pattern/match 匹配与 tab 展示,
// 其余字段原样进 file.params 透传给编辑器组件
actions.openEditorItemForProject(project.id, {
id: 'proto:42', // 自定义稳定键:同 id 重复调用只激活不重建
name: 'x.prototype.json', // 标准文件名,命中 declares patterns: ['*.prototype.json'] 的编辑器
protoId: 42, // ——以下全是自定义参数——
source: 'cloud',
ownerId: 'u-123'
});
编辑器组件侧接收(与普通文件一致,多出 virtual / params 两个字段):
export function PrototypeCanvasPanel ({ file }) {
// file.virtual === true 表示非磁盘文件,宿主不会预读 content(恒为 null)
// file.params = { protoId: 42, source: 'cloud', ownerId: 'u-123' } —— 调用方自定义参数
const { protoId, source } = file.params || {};
// 编辑器自行取内容(示例:走网关)
// const data = await apiClient.get(`/protos/${protoId}`);
}
⚠️ 不要用底层原语
openFileForProject——只登记 tab、不激活,中央不切换(见 8.3 踩坑点)。 ⚠️ 入口 B 的path是宿主合成的内部键(editor-item:<id>),不是磁盘路径——编辑器内禁止把它当路径传给fs.*/onOpenFile;要落盘就先由编辑器写入真实路径再走入口 A。
10.4 Editor → Panel
fileEditor 组件与普通面板收到同一组宿主注入(见 7.3 props 表):onOpenPanelItem / onClosePanelItem / onOpenFile / onInsertTextToChat,以及 SDK actions。从编辑器里跳转中央面板的写法与 10.2 完全一致:
export function PrototypeCanvasPanel ({ file, onOpenPanelItem }) {
const openLayer = (layer) => {
onOpenPanelItem({
id: 'layer-' + layer.id,
tabPrefix: 'prototype:layer:', // 命中某个声明了该前缀的 centerPanel
title: layer.name,
payload: { layer }
});
};
// ...
}
10.5 禁止事项
- 不要直接调 store 底层(
setSelectedSession/ 手拼 session id)切激活态——激活态由宿主统一管理(见 9.5 警告)。 - 不要 import 其它插件或宿主源码;复用宿主组件只走
sdk.panels/sdk.editors(见 7.4)。 - 不要自己复刻「发给 AI」的落点逻辑,一律走
onInsertTextToChat(见 7.5)。 tabPrefix命不中时先排查双方声明是否一致、openPanelItemForProject参数是否非空(见 14 节 Q5)。
11. 主进程能力:main.cjs(Cordis 插件)
main.cjs 是插件的主进程入口(可选)。渲染端 UI 走 renderer/index.jsx;主进程能力(cap、本地存储、Agent 工具、项目初始化钩子)必须走 main.cjs——渲染端不能碰 ipcMain、敏感路径、原生模块。
11.1 什么时候需要 main.cjs
- 需要本地文件 / 数据库持久化(私有配置、缓存)。
- 需要 IPC 通道与 preload / webview 通信。
- 需要注册主进程能力(cap)、Agent 工具或项目初始化钩子。
- 纯 UI 插件不需要
main.cjs。
11.2 契约(Cordis:只 apply)
'use strict';
module.exports = {
apply (ctx) {
// 所有初始化逻辑放这里;ctx 上已有全部 Cordis 服务与宿主注入字段
ctx.cap.define('myPlugin', {
impl: { ping: async ({ msg } = {}) => ({ pong: msg }) }
});
// 卸载时只需清理自己 new 的句柄:
const timer = setInterval(() => {}, 1000);
ctx.on('dispose', () => clearInterval(timer));
}
};
- 宿主按
manifest.main || 'main.cjs'加载,用rootContext.plugin((ctx) => mod.apply(ctx))调用。 - 只导出
apply;不要写顶层副作用。 - 卸载时宿主持自动回收:cap(
hostCap.removeByOwner)、contributes.*(面板/编辑器/模式/技能)、DSL 扩展(dslRegistry.unregisterByOwner)。你只清理自己 new 的句柄。
main.cjs(CJS,主进程)与renderer.mjs(ESM,渲染进程)是两个独立产物,通过 cap 通道桥接(渲染端getCapability/sdk.invoke,主进程ctx.cap),不要在渲染端require主进程代码。
11.3 ctx 上下文(你能拿到什么)
Cordis 服务(根上下文注册、子上下文继承,直接 ctx.xxx 取):
| 服务 | 用途 |
|---|---|
ctx.cap |
跨进程能力服务。声明 ctx.cap.define(capId, { schema, impl }),自动注册 IPC 通道 cap:<capId>:<method>,卸载按 owner 摘掉。详见第 12 节。 |
ctx.db |
宿主已编译好 ABI 的 better-sqlite3:ctx.db.Database(构造器)/ ctx.db.open(path, opts)。不要自行 require('better-sqlite3')(原生绑定 ABI 与 Electron 不一致)。 |
ctx.harness |
编排运行时扩展:ctx.harness.registerNode(def) / ctx.harness.registerTool(def) 把节点/工具注入到 rundls 编排。 |
ctx.electron |
宿主原生 Electron API 出口(幂等封装):ctx.electron.ipcMain / ctx.electron.BrowserWindow / ctx.electron.app / ctx.electron.dialog / ctx.electron.getMainWindow()。不要裸 require('electron')。 |
ctx.file |
文件能力:readFile / writeFile / readJson / writeJson / exists / ensureDir / readdir / rm / copy / expandPath。 |
ctx.network |
网络能力(fetch / 下载)。 |
宿主注入字段 / 方法(见第 2.3 节,不是 Cordis API):
| 字段 / 方法 | 用途 |
|---|---|
ctx.pluginId / ctx.manifest |
本插件 id / 完整清单 |
ctx.pluginDir |
插件目录绝对路径(读自己的子模块用) |
ctx.dataDir |
插件私有数据目录 ~/.wegirl-office/plugin-data/<id>/,卸载不删、升级不丢 |
ctx.logger |
日志对象,建议前缀 [Plugin:<id>] |
ctx.registerIpc(fn) |
注册全局 IPC 通道(包裹错误兜底) |
ctx.registerInvokeHandlers(map) |
注册命名方法,渲染端旧式 api.plugins.invoke(id, method, payload) 调用 |
ctx.registerProjectInitializer({ scenario, fn }) |
注册新项目创建钩子 |
ctx.registerAgentTools(tools) |
注册 Agent 工具 |
ctx.registerClipRoute(routes) |
注册剪贴板路由(进阶) |
11.4 私有数据与持久化
放 ctx.dataDir,不要写项目目录或 app.getPath('userData') 根;不要写 project.json / 会话数据库。
const fs = require('fs');
const path = require('path');
module.exports = {
apply (ctx) {
const cfgPath = path.join(ctx.dataDir, 'config.json');
fs.mkdirSync(ctx.dataDir, { recursive: true });
ctx.registerInvokeHandlers({
getConfig: async () => {
try { return JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { return {}; }
},
setConfig: async (cfg) => {
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
return { ok: true };
}
});
}
};
11.5 项目初始化钩子
module.exports = {
apply (ctx) {
ctx.registerProjectInitializer({
scenario: 'static_site',
fn: async (project) => {
await fs.promises.mkdir(path.join(project.path, 'dist'), { recursive: true });
ctx.logger.log('[Plugin:my] 初始化 static_site', project.path);
}
});
}
};
仅当 project.scenario 命中时调用;多个插件可各自注册,全部执行。宿主在 onProjectCreated 里按 scenario 遍历 registry.projectInitializers 触发。
11.6 注册 Agent 工具
category: 'agent-tool' 的插件用 ctx.registerAgentTools 把工具交给宿主:
const TOOLS = [{
name: 'MyTool',
description: '给模型的说明(写清参数与副作用)',
parameters: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
op: 'custom', // 必须 'custom',引擎才路由到 run
accessType: 'readonly', // readonly | exclusive | file
requiresApproval: false, // 有副作用(发布 / 删除 / 写文件)必须 true
run: async (args, runCtx) => '结果'
}];
module.exports = { apply: (ctx) => ctx.registerAgentTools(TOOLS) };
与通用编排的关系(2026-09-13 起):插件工具注册后进宿主「工具目录」,不会自动灌给模型——编排 llm 节点勾选才注入。若不想为每个自定义工具专门编排,勾选内置元工具
ToolKit(skill 加载器模式)即可:其描述运行期内嵌完整工具目录,模型按需{ tool, args }调用任意目录工具(含本插件工具),目标工具各自的requiresApproval审批仍然生效。通用编排建议常驻勾选 ToolKit。
11.7 全局 IPC(仅与 preload / webview 通信时用)
module.exports = {
apply (ctx) {
ctx.registerIpc((c) => {
c.ipcMain.handle('myplugin:getConfig', async (_, projectId) => ({ ok: true }));
});
}
};
- 通道命名加
pluginId:前缀避免撞车;私有方法一律走registerInvokeHandlers或ctx.cap。 - IPC 必须走
ctx.electron.ipcMain,禁止裸require('electron').ipcMain(否则 reload 抛二次注册 / 累积 listener)。ctx.electron.ipcMain已包成幂等代理(handle注册前先removeHandler、on注册前先removeAllListeners),插件 reload 安全。
11.8 开发规范(硬性)
- 只
requireNode 内置模块与ctx注入的能力;不要require宿主源码或自行加载原生模块(原生用ctx.db.Database/ctx.services)。 - 所有初始化放进
apply,不要写顶层副作用;只导出apply。 - 卸载只需清理自己 new 的句柄;cap / 面板 / DSL 由宿主按 owner 自动回收。
- 跨进程能力优先用
ctx.cap.define(capId, { impl })(见第 12 节);cap impl 方法一律用单对象解构签名,否则位置参数恒为 undefined。 - 全局 IPC 通道加
pluginId:前缀;IPC 必须走ctx.electron.ipcMain,禁止裸require('electron').ipcMain。 ipcMain.handle必须返回可序列化结果;ipcMain.on无返回值。- 日志统一前缀
[Plugin:<id>]。 - 改
main.cjs后必须重新构建并重启(渲染端 UI 改动需 reload 窗口)。 - 不要让
apply抛未捕获异常,内部逻辑用try/catch兜底。
12. 跨进程能力:ctx.cap.define(推荐)
插件经 ctx.cap.define(capId, { schema, impl }) 声明跨进程能力,宿主自动:
- 注册 IPC 通道
cap:<capId>:<method>(幂等,reload 安全); - 统一包裹
{ success, ... }信封(成功摊平 / 数组进data/ 异常变{ success:false, error }); - 按调用该方法的插件 id 自动归属 owner,卸载时
removeByOwner干净摘掉; - 同一
capId多次define会合并方法与 schema(不丢方法),适合宿主多处在同 capId 上增量注册。
12.1 主进程:声明
module.exports = {
apply (ctx) {
ctx.cap.define('myPlugin', {
// schema 可选:给每个方法写入参 JSON Schema(渲染端 registry 可读,便于自动生成表单)
schema: {
ping: { type: 'object', properties: { msg: { type: 'string' } } }
},
impl: {
// ✅ 单对象解构签名(宿主把 payload 作为单个对象参数传入)
ping: async ({ msg } = {}) => ({ pong: msg }),
// 需要向调用窗口推送进度:把 event 声明为【最后一个】形参
runWithProgress: async (taskId, event) => {
event.sender.send('myPlugin:progress', { taskId, p: 50 });
return { success: true, done: true };
}
}
});
}
};
- capId 命名用
.分层命名空间,例如socialMedia.topicLibrary/mail/knowledge/whatsapp。 - 信封
{ success:true, ... }由宿主统一包裹,impl 不必手写;异常自动变{ success:false, error }。 - cap 是请求-响应模型:用
event.sender.send(...)主动推送进度的方法,把event声明为最后一个形参(宿主作为尾参透传);若进度逻辑复杂无法改造,保留原生ctx.electron.ipcMain.handle(见 11.7)。
12.2 渲染端:调用(任选其一)
// ① getCapability:能力方法代理(唯一入口,Electron / 网页通用):原样返回信封 { success, ... }
import { getCapability } from '@wegirl/sdk';
const myPlugin = getCapability('myPlugin');
const res = await myPlugin.ping({ msg: 'hi' }); // { success, ... },不抛错
if (!res.success) throw new Error(res.error);
// ② callCap(SDK 内置,见 8.1):已封装「解包+抛错」语义
import { callCap } from '@wegirl/sdk';
const r = await callCap('myPlugin', 'ping', { msg: 'hi' });
两条路径的返回值不同(见 8.1):
getCapability原样返回信封、不抛错;callCap解包信封、失败抛错。按你用的入口选择res.success判断或try/catch。
12.3 与旧式 registerInvokeHandlers / api.plugins.invoke 的关系
ctx.registerInvokeHandlers({ method: fn })(宿主注入方法)注册的命名方法,渲染端经sdk.invoke(pluginId, method, payload)调用(桌面 IPC 与云端代理自动路由)。宿主PluginManager.invoke在该 map 找不到时,自动回退到hostCap.invokeByOwner(pluginId, method)——即你的ctx.cap.define方法也能被sdk.invoke调到。- 新插件能力通道一律
ctx.cap.define(主进程)+callCap/getCapability/sdk.invoke(渲染端)。
12.4 事件型推送(进度 / 日志)
cap 是请求-响应模型,主动进度走 event.sender.send(channel, payload)(方法尾参拿 event)。纯推送型通道(如 desktop:wikiIngest:progress)由主进程 ctx.electron.ipcMain 直接 webContents.send(...),渲染端用 sdk.events.on(channel, cb)(或插件内 onCap 助手,见 8.1)订阅,不进 cap。
13. 场景(scenario)开发
场景把「一组能力」绑定到「一类项目」。插件声明自己属于哪个 scenario,宿主在创建 / 打开对应场景项目时自动注入面板,无需在宿主写 if (scenario === '...')。
13.1 两个维度
| 维度 | 取值 | 含义 |
|---|---|---|
projectType |
code / work |
项目大分组,决定创建弹窗第一级选择。 |
scenario |
code / static_site / email / whatsapp / social-media / cross-border / knowledge / prototype / 自定义… |
具体场景,面板按它门控。 |
13.2 声明场景
写在插件顶层 scenario,contributes 子项继承,不再单独声明:
{
"id": "email",
"scenario": "email",
"projectType": "work",
"contributes": {
"panels": [
{ "id": "email_list", "component": "EmailList", "mountPoint": "fileTree" },
{ "id": "email", "component": "EmailDetail", "mountPoint": "centerPanel" }
]
}
}
13.3 匹配规则
- 项目没声明场景 → 只放行全局插件(插件
scenario为空 /*,或宿主内置场景)。 - 插件没声明场景 → 对所有项目生效。
- 否则必须
项目.scenario === 插件.scenario才命中。
不要试图在子项写
scenario绕过——子项继承顶层。
13.4 实战:给 static_site 加「构建日志」右侧面板
plugin.json:
{
"id": "static-site-tools",
"name": "静态站工具",
"version": "1.0.0",
"engine": "wegirl-plugin@1",
"category": "scene",
"scenario": "static_site",
"projectType": "code",
"contributes": {
"panels": [
{ "id": "ss_build_log", "title": "构建日志", "icon": "📜", "component": "BuildLogPanel", "mountPoint": "rightPanel" }
]
}
}
renderer/index.jsx:
export { BuildLogPanel } from './components/BuildLogPanel';
renderer/components/BuildLogPanel.jsx:
import React from 'react';
import { actions } from '@wegirl/sdk';
export function BuildLogPanel ({ project, theme, onOpenFile }) {
const openLog = () => actions.openAndLoadFile(project.id, {
path: project.path + '/build.log', name: 'build.log'
});
return (
<div style={{ padding: 12, color: theme === 'light' ? '#111' : '#eee' }}>
<button onClick={openLog}>打开 build.log</button>
</div>
);
}
构建并链接后,任意 static_site 项目的右侧 tab 自动出现「构建日志」,无需改动宿主任何代码。
13.5 云端场景
- 桌面专属能力(如邮件 / WhatsApp 本地能力)设
supportsWeb: false,网页 / 云端会过滤掉。 - 想在云端(网关直建)可选中该场景,清单加
"supportsCloudProject": true。
13.6 新增一个完整场景插件的步骤
- 在
wegirl-office/plugins/<id>/建目录与清单,顶层写scenario+projectType+contributes。 - 写
renderer/index.jsx具名导出组件;组件用@wegirl/sdk取能力。 - (可选)
main.cjs提供 cap 与本地存储(只导出apply)。 - 在「插件开发」工作台(plugin-dev)选中该插件,点「编译并安装」(见第 4 节)。核心开发者也可用仓库
npm run plugin:build <id>+node plugins/scripts/install-local.mjs <id> --link。 - 宿主创建项目弹窗自动出现该场景(已安装即出现;云端需
supportsCloudProject: true)。 - 打开该场景项目,确认面板 / 虚拟入口 / 文件树按场景注入正常。
14. 常见问题与踩坑
Q1:插件面板没出现在 tab 栏
- 检查
mountPoint是否为editorTab(动态内容用centerPanel,不进 tab 栏)。 - 检查
component是否在renderer/index.jsx正确导出。 - 检查
scenario是否与当前项目匹配。
Q2:文件没用我注册的编辑器
- 检查
pattern/match能否命中路径。 - 检查
priority是否被其它条目覆盖。 - 检查
component是否导出。
Q3:一个插件能注册多个不同 mountPoint 的面板吗
- 能,
panels是数组,每个条目可不同mountPoint。
Q4:中间面板在左侧 / 顶部出现了,但我不想占这些入口
- 改用
mountPoint: "centerPanel",它仍在中间渲染,但不注册树节点、不进 tab 栏。
Q5:右侧面板调了 onOpenPanelItem,中央没反应
- 检查
tabId/tabPrefix是否与某插件面板声明匹配(RegisteredPluginPanelByTab按centerPanel → editorTab → centerOverlay查找)。 - 若用了
tabPrefix,确认对应面板也写了相同tabPrefix。 - 确认
openPanelItemForProject被调用(projectId与item.id非空)。 - 改了源码记得重新构建并同步
wegirl-office/plugins/dist。
Q6:主进程 main.cjs 改了没生效
- 重新构建插件并重启 Electron(或商店 reload);渲染端 ESM 无法热卸载,UI 改动需 reload 窗口。
Q7:cap 报 xxx 必填(如 a 必填)
- 几乎都是 cap 方法用了位置参数签名导致形参恒为 undefined(见 8.1 / 12.1 陷阱)。改成单对象解构
async ({ a, b } = {}) => {...},重新构建并重启。
Q8:改了源码不生效
- 没重建(见第 4 节铁律)。软件内「插件开发」工作台点「编译并安装」后
dist才更新并热重载;仓库内部开发者等价npm run plugin:build <id>。
Q9:整树白屏 Element type is invalid ... resolves to null
- 清单
component名与renderer/index.jsx具名导出不一致。
Q10:面板无样式
- 插件 CSS 由宿主注入的
<style>加载,不要自己写<link href="wegirl-plugin://...">(自定义协议<link>会被静默丢弃)。
Q11:两棵 React 树 / Hooks 报错
- 渲染端把
react打进去了,确认构建脚本 externalize React / ReactDOM(不要import 'react')。
Q12:@wegirl/sdk 具名导入的字段取不到(undefined)
- 该字段可能只挂在默认导出的
sdk对象上、没有具名导出(见 8.2 导出契约)。改用import sdk from '@wegirl/sdk'然后sdk.xxx。
Q13:ctx.wegirl.cap / ctx.ipcMain / ctx.services 取不到
- 这些是旧版路径,已被 Cordis 服务取代:
ctx.cap(能力)、ctx.electron.ipcMain(IPC)、ctx.db.Database(sqlite)。ctx.wegirl嵌套已删除。
Q14:插件卸载后 cap / 面板还在
- 不应发生。宿主在
dispose时按 owner 自动回收ctx.cap、contributes.*、ctx.harnessDSL。若残留,多半是你在apply里自己require('electron').ipcMain.handle(...)(裸 IPC 不归 Cordis 账本),改用ctx.electron.ipcMain或ctx.cap。
15. 完整示例:sandbox 顶层模式插件
这是把重型依赖(Phaser)从主包剥离、作为 modes 按需加载的真实案例。
wegirl-office/plugins/sandbox/plugin.json(源码):
{
"id": "sandbox",
"name": "沙盘模式",
"version": "1.0.0",
"engine": "wegirl-plugin@1",
"supportsWeb": true,
"category": "scene",
"description": "等轴办公室沙盘:Phaser 实时场景,作为插件按 contributes.modes 声明顶层模式,本地与网页共用同一份产物。",
"main": "main.cjs",
"renderer": "renderer.mjs",
"contributes": {
"modes": [
{ "id": "sandbox", "label": "沙盘", "icon": "🎮", "component": "SandboxDashboard" }
]
}
}
wegirl-office/plugins/sandbox/main.cjs(薄主进程,只 apply):
'use strict';
// ✅ Cordis:只导出 apply;宿主以 Cordis 子上下文调用,可拿到 ctx.cap / ctx.db / ctx.electron。
module.exports = {
apply (ctx) {
ctx.logger.log('[Plugin:sandbox] loaded');
// 若需要清理自己 new 的句柄:
// ctx.on('dispose', () => { /* 清理 */ });
}
};
wegirl-office/plugins/sandbox/renderer/index.jsx(入口具名导出):
export { SandboxDashboard } from './SandboxDashboard';
wegirl-office/plugins/sandbox/renderer/SandboxDashboard.jsx(节选,使用宿主注入的 mode props + SDK):
import sdk from '@wegirl/sdk';
import { useSandboxState } from './useSandboxState';
export function SandboxDashboard ({ humanPlayerId, onLogout, onSwitchMode, onSwitchOrg, onUpgrade }) {
const state = useSandboxState({ humanPlayerId });
// ... 渲染 Phaser 画布、工具栏、侧边栏,Phaser 仅在进入此组件时随插件产物按需加载 ...
return (/* ... */);
}
要点:
- Phaser 在插件渲染端显式
import Phaser from 'phaser'(宿主原场景靠全局注入,插件必须自己引)。 - 资源路径改为站点根绝对路径
/tilesets、/maps(插件产物相对路径.会 404)。 - 状态自包含(
useSandboxState),不再依赖宿主 store;拉取 agent 任务用await sdk.wegirlFetch(\${sdk.apiBase}/agents/...`)`。 - 构建:在「插件开发」工作台选中
sandbox,点「编译并安装」即可(见第 4 节);核心开发者等价命令是npm run plugin:build sandbox+node plugins/scripts/install-local.mjs sandbox --link。 - 效果:主包
vendor-phaser从引擎体积变为 0.05 KB 空 chunk,Phaser 仅在进入沙盘时下载。
如有新的挂载点或贡献类型需求,请先更新本文档,再修改宿主注册表逻辑,保持文档与代码同步。