← 返回文档总览

WeGirlOffice 插件开发指南

面向插件开发者,介绍「如何开发插件、如何把功能注册到界面、如何调用宿主能力」。


目录

  1. 心智模型:插件能做什么
  2. Cordis 运行时模型(必读)
  3. 目录与源码 / 产物位置
  4. 构建、链接与调试命令
  5. plugin.json 清单字段
  6. 贡献类型(contributes)
  7. 渲染端组件开发
  8. 调用宿主能力:@wegirl/sdk 与 cap
  9. 打开中央面板:panelItem 接口
  10. 组件互调指南:Panel / Editor / 会话
  11. 主进程能力:main.cjs(Cordis 插件)
  12. 跨进程能力:ctx.cap.define(推荐)
  13. 场景(scenario)开发
  14. 常见问题与踩坑
  15. 完整示例:sandbox 顶层模式插件

1. 心智模型:插件能做什么

插件通过 plugin.jsoncontributes 向宿主贡献界面能力,通过 main.cjsapply(ctx) 向宿主贡献主进程能力(cap / Agent 工具 / 项目初始化钩子 / 剪贴板路由)。

能力 注册位置 说明
中间面板 contributes.panels 在中间工作区渲染(可同时出现在左侧项目树 + 顶部 tab,或仅动态展开)。
文件编辑器 contributes.fileEditors 把特定文件类型(按后缀 / 路径)映射到专属编辑器组件。
顶层模式 contributes.modes 声明一个顶层模式(如沙盘),宿主的模式选择器从硬编码 office 扩展为「内置 office + 插件 modes」,按需懒加载整份插件产物(典型用例:重型依赖 Phaser 只在进入沙盘时下载)。
技能 contributes.skills skills/<name>/SKILL.md 随项目创建复制到 .agents/skills/<name>
主进程能力 main.cjsapply(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 }) }
    });
  }
};

2.2 生命周期 / 卸载:统一走 dispose

Cordis 卸载插件时只触发 dispose。宿主在子上下文上为此挂了统一回收器,会自动摘掉该插件注册的一切:

只需要清理「自己 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.cjsrootContext.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 起,无软链):

约定:


4. 构建、链接与调试命令

插件有两种构建 / 安装方式。第三方开发者一律走 4.1(软件内「插件开发」工作台)plugins/scripts/build.mjs 等 npm 脚本只存在于宿主仓库内部、随仓库分发,不会随安装包 / 插件源码提供给外部开发者,不要把它写进对外的构建指引。

4.1 推荐:插件开发工作台(plugin-dev,所有开发者通用)

这是官方唯一对外的构建 / 链接入口——编译能力随安装包分发(esbuild 已 asar 解包),第三方开发者不需要拉宿主仓库、不需要自己装 Node / esbuild、不需要 plugins/scripts/build.mjs

  1. 在软件内打开「插件开发」场景项目(plugin-dev scenario)。右侧出现「插件」tab,里面是该工作台。
  2. 该项目根(即 project.path)就是你的插件工作区根:根下每个含 plugin.json 的子目录 = 一个插件(与仓库 WEGIRL_PLUGINS_HOME 约定一致)。
  3. 在列表选中插件,点 「编译并安装」 按钮 —— 它一次性完成:
    • 内联构建:主进程直接调 esbuild 编译(不 spawn 系统 node、不调仓库 build.mjs),产物落 <项目根>/dist/<id>
    • 自动加载:宿主自动扫描插件开发项目的 dist/<id>(本地开发直读,无需任何软链 / 安装步骤);
    • 热重载:触发宿主 PluginManager.reload,主进程 + 渲染端立即生效,无需重启 Electron
  4. 改完源码 → 再点一次「编译并安装」即生效。只想刷新已装插件(不再构建)用 「热重载」 按钮(主进程 + 渲染端立即生效,UI 改动必要时会重载窗口)。
  5. 「打包」 按钮:把 dist/<id> 打成 zip 落到 <项目根>/staging/<id>-<version>.zip(版本号自动 +1),用于发商店 / 分发。
  6. 脚手架:在列表里填 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.mjsinstall-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 缓存。

调试:


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 / contributesstyle / 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 模式标识。宿主 Appmode 状态等于此值(如 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 组件约定

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/sdksdk.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>;
}

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 追加输入框

第二参数 optstrue{ shiftKey: true } → 不落当前会话,弹宿主「发送到会话」选择器,由用户挑目标会话(选完同样只进输入框,不自动发送)。

保证的行为(宿主实现,插件无需关心):

⚠️ 不要复刻落点逻辑:旧做法是在插件里 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.jscreateHostSdk)+ 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.EditorShelleditors.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 其它

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 其它常用动作 / 状态

实际可用方法以 @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 前缀匹配。只要你的面板上声明了对应的 idtabPrefix 即可,无需在宿主写判断。

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

目标面板先在清单里声明可命中标识centerPaneltabPrefix,或 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
});

10.3 Panel → Editor

不存在「直接调用编辑器组件」的通道。把文件交给宿主,宿主按 contributes.fileEditorspattern / 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 禁止事项


11. 主进程能力:main.cjs(Cordis 插件)

main.cjs 是插件的主进程入口(可选)。渲染端 UI 走 renderer/index.jsx;主进程能力(cap、本地存储、Agent 工具、项目初始化钩子)必须走 main.cjs——渲染端不能碰 ipcMain、敏感路径、原生模块

11.1 什么时候需要 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));
  }
};

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-sqlite3ctx.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 }));
    });
  }
};

11.8 开发规范(硬性)

  1. require Node 内置模块与 ctx 注入的能力;不要 require 宿主源码或自行加载原生模块(原生用 ctx.db.Database / ctx.services)。
  2. 所有初始化放进 apply,不要写顶层副作用;只导出 apply
  3. 卸载只需清理自己 new 的句柄;cap / 面板 / DSL 由宿主按 owner 自动回收。
  4. 跨进程能力优先用 ctx.cap.define(capId, { impl })(见第 12 节);cap impl 方法一律用单对象解构签名,否则位置参数恒为 undefined。
  5. 全局 IPC 通道加 pluginId: 前缀;IPC 必须走 ctx.electron.ipcMain,禁止裸 require('electron').ipcMain
  6. ipcMain.handle 必须返回可序列化结果;ipcMain.on 无返回值。
  7. 日志统一前缀 [Plugin:<id>]
  8. main.cjs 后必须重新构建并重启(渲染端 UI 改动需 reload 窗口)。
  9. 不要让 apply 抛未捕获异常,内部逻辑用 try/catch 兜底。

12. 跨进程能力:ctx.cap.define(推荐)

插件经 ctx.cap.define(capId, { schema, impl }) 声明跨进程能力,宿主自动:

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 };
        }
      }
    });
  }
};

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 的关系

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 声明场景

写在插件顶层 scenariocontributes 子项继承,不再单独声明:

{
  "id": "email",
  "scenario": "email",
  "projectType": "work",
  "contributes": {
    "panels": [
      { "id": "email_list", "component": "EmailList", "mountPoint": "fileTree" },
      { "id": "email", "component": "EmailDetail", "mountPoint": "centerPanel" }
    ]
  }
}

13.3 匹配规则

不要试图在子项写 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 云端场景

13.6 新增一个完整场景插件的步骤

  1. wegirl-office/plugins/<id>/ 建目录与清单,顶层写 scenario + projectType + contributes
  2. renderer/index.jsx 具名导出组件;组件用 @wegirl/sdk 取能力。
  3. (可选)main.cjs 提供 cap 与本地存储(只导出 apply)。
  4. 在「插件开发」工作台(plugin-dev)选中该插件,点「编译并安装」(见第 4 节)。核心开发者也可用仓库 npm run plugin:build <id> + node plugins/scripts/install-local.mjs <id> --link
  5. 宿主创建项目弹窗自动出现该场景(已安装即出现;云端需 supportsCloudProject: true)。
  6. 打开该场景项目,确认面板 / 虚拟入口 / 文件树按场景注入正常。

14. 常见问题与踩坑

Q1:插件面板没出现在 tab 栏

Q2:文件没用我注册的编辑器

Q3:一个插件能注册多个不同 mountPoint 的面板吗

Q4:中间面板在左侧 / 顶部出现了,但我不想占这些入口

Q5:右侧面板调了 onOpenPanelItem,中央没反应

Q6:主进程 main.cjs 改了没生效

Q7:cap 报 xxx 必填(如 a 必填)

Q8:改了源码不生效

Q9:整树白屏 Element type is invalid ... resolves to null

Q10:面板无样式

Q11:两棵 React 树 / Hooks 报错

Q12:@wegirl/sdk 具名导入的字段取不到(undefined)

Q13:ctx.wegirl.cap / ctx.ipcMain / ctx.services 取不到

Q14:插件卸载后 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 (/* ... */);
}

要点:


如有新的挂载点或贡献类型需求,请先更新本文档,再修改宿主注册表逻辑,保持文档与代码同步。