Workbench

在插件之间复用界面

复用设置页和选择器,明确页面、数据与放置位置分别属于哪个插件。

当 FontsPlugin 的选择器要出现在 Canvas 的详情页,或者多个插件要共用 HTTP 设置界面时,使用 Attachment。 提供界面的插件维护 React 代码和服务端 API;使用界面的插件只选择放置位置。

开始前,先完成一个 View,并让使用方通过构造器声明对提供方的依赖。 下文把提供界面的插件称为 provider,把放置它的插件称为 consumer。

先选一个 API,还是两个 API

谁保存页面修改的数据选择
全部由 provider 保存单方 API,先照下面的 HTTP 设置页做
provider 提供候选,consumer 保存自己的选择双方 API,见多 collection 且 consumer 自有选择
不需要跨插件显示 UI,只调用服务端方法普通构造器依赖
只想共用按钮或布局,没有跨插件 API普通 React 组件和 props

Attachment 不自动改变依赖、登录权限或存储位置。一次打开得到的 API 对象不能拿去给其他页面复用。

Provider-only 设置页

以共享 HTTP provider 为例:provider 已按 caller node 保存设置,consumer 只希望在自己的详情页放置 provider 的设置页。

先在提供方的 src/workbench.ts 声明 API 和 Attachment。它与 View 的区别是没有固定 placement:

import type { RpcTarget } from 'capnweb'
import { workbench } from '@pluxel/workbench'

export type HttpSettings = Readonly<{
	headers: Readonly<Record<string, string>>
	timeoutMs?: number
}>

export interface HttpSettingsApi extends RpcTarget {
	snapshotDto(): HttpSettings
	updateDto(input: HttpSettings): Promise<HttpSettings>
	resetDto(): Promise<HttpSettings>
}

export const HttpWorkbench = workbench.define({
	settings: workbench.attachment<HttpSettingsApi>({
		renderer: workbench.entry(import.meta.url, './ui/settings.tsx'),
	}),
})

这些 *Dto 方法由 target 完成授权、输入校验和 DTO 投影,并在返回前调用 assertWorkbenchDto(); 具体边界见 API 契约。

然后在提供方 init() 中发布。下面假设业务服务 settings 已按使用方保存设置,HttpSettingsTarget 是实现该 API 的 RpcTarget:

this.ctx.workbench?.publish(HttpWorkbench, {
	settings: ({ consumer, signal }) =>
		new HttpSettingsTarget(this.settings.require(consumer.node), signal),
})

consumer.node 只是 server-issued identity。Provider 可以用它查找自己已经拥有的 per-consumer state,但不能 取得 consumer instance、Context、config 或任意 dependency facade。

最后在使用方通过 place() 声明页面入口,并传入构造器注入的 provider。下面是使用方的关键代码;HttpPlugin 从提供方包根入口导入:

import { BasePlugin, Plugin } from '@pluxel/core'
import { HttpPlugin } from '@acme/http'
import { HttpWorkbench } from '@acme/http/workbench'
import { workbench } from '@pluxel/workbench'

export const ReportsWorkbench = workbench.define({
	http: HttpWorkbench.settings.place(
		workbench.tab({ label: 'HTTP', icon: workbench.icons.Settings }),
	),
})

@Plugin()
export class ReportsPlugin extends BasePlugin {
	constructor(private readonly http: HttpPlugin) {
		super()
	}

	override init() {
		this.ctx.workbench?.publish(ReportsWorkbench, {
			http: { provider: this.http },
		})
	}
}

Renderer 仍位于 provider package:

import { createWorkbenchRenderer } from '@pluxel/workbench/react'
import { HttpWorkbench } from '../workbench.ts'

const settingsScope = createWorkbenchRenderer(HttpWorkbench.settings)
const httpSettingsQuery = settingsScope.query(({ provider }) => ({
	queryKey: ['http', 'settings'] as const,
	queryFn: () => provider.snapshotDto(),
}))

function HttpSettingsPanel() {
	const { host } = settingsScope.useWorkbench()
	const settings = httpSettingsQuery.useQuery()
	// render host + immutable settings.data
}

export default settingsScope.render(HttpSettingsPanel)

这条路径只有一个 renderer 和一个 provider API。Consumer 不实现转发 target,不复制 provider 的 UI,也不把 设置 state 搬到 Workbench。

从“应用”目录打开使用方的 HTTP 入口,内容应来自提供方的 renderer。停止使用方会移除这个位置, 但不删除提供方的设置;关闭 Workbench 后,服务端构造器依赖仍应可用。 如果入口缺失,检查双方已经运行、提供方已发布 Attachment、使用方的 binding key 与 .place() 声明相符。

官方 FontsPlugin:先用 provider-only

FontsPlugin 的 FontsWorkbench.selection 是现成示例:Fonts 拥有字体与统一默认 family,Canvas、ECharts、Takumi 只放置同一个选择器。consumer stop 撤销 placement,不删除 Fonts 的数据;关闭 Workbench 也不改变服务端字体能力。

不要因页面位于 consumer 下,就复制一份 consumer selection。只有 consumer 确实拥有独立设置时,才使用下面的双 API。

多 collection 且 consumer 自有选择

只有产品确实同时满足下面三个条件,才需要从 provider-only Attachment 升级成双 root:

  • provider 拥有多个动态 collection;
  • consumer 持久化自己的 collectionId 与 fallback policy;
  • picker 同时需要读 provider catalog 和修改 consumer selection。

以下是扩展示例,不是官方 FontsPlugin 已有功能。provider 拥有候选,consumer 拥有选择;两个 API 仅暴露各自任务需要的读取与写入:

import type { RpcTarget } from 'capnweb'
import { workbench } from '@pluxel/workbench'

type CollectionPage = Readonly<{
	items: readonly Readonly<{ id: string; name: string }>[]
	nextCursor: string | null
}>
type Selection = Readonly<{ revision: number; collectionId: string | null }>

interface FontCatalogApi extends RpcTarget {
	listDto(input: { cursor: string | null; limit: number }): Promise<CollectionPage>
}
interface FontSelectionApi extends RpcTarget {
	snapshotDto(): Promise<Selection>
	selectDto(input: {
		collectionId: string | null
		expectedRevision: number
	}): Promise<
		| Readonly<{ ok: true; value: Selection }>
		| Readonly<{ ok: false; code: 'conflict' | 'collection_missing' }>
	>
}

export const FontManagerWorkbench = workbench.define({
	collectionPicker: workbench.attachment<FontCatalogApi, FontSelectionApi>({
		renderer: workbench.entry(import.meta.url, './ui/collection-picker.tsx'),
	}),
})

Provider 发布 catalog root:

this.ctx.workbench?.publish(FontManagerWorkbench, {
	collectionPicker: ({ consumer, principal, signal }) =>
		new FontCatalogTarget(this.collections.visibleTo(consumer.node, principal), signal),
})

Canvas 等 consumer 放置 picker,并发布自己的 selection root:

export const CanvasWorkbench = workbench.define({
	fonts: FontManagerWorkbench.collectionPicker.place(workbench.tab({ label: 'Fonts', order: 30 })),
})

this.ctx.workbench?.publish(CanvasWorkbench, {
	fonts: {
		provider: this.fonts,
		consumer: ({ principal, signal }) =>
			new CanvasFontSelectionTarget(this.settings, this.fonts, {
				principal,
				signal,
			}),
	},
})

Picker renderer 精确得到两个 owners:

import { createWorkbenchRenderer } from '@pluxel/workbench/react'
import { FontManagerWorkbench } from '../workbench.ts'

const collectionPickerScope = createWorkbenchRenderer(FontManagerWorkbench.collectionPicker)
const fontCatalogQuery = collectionPickerScope.query(({ provider }) => ({
	queryKey: ['fonts', 'catalog'] as const,
	queryFn: () => provider.listDto({ cursor: null, limit: 50 }),
}))
const fontSelectionQuery = collectionPickerScope.query(({ consumer }) => ({
	queryKey: ['fonts', 'consumer-selection'] as const,
	queryFn: () => consumer.snapshotDto(),
}))

function FontCollectionPicker() {
	const { host } = collectionPickerScope.useWorkbench()
	// provider catalog and consumer selection stay separate query resources
}

export default collectionPickerScope.render(FontCollectionPicker)

Provider root 不代理 consumer selection,consumer root 也不转发 catalog。两个 target 各自保留 owner admission 与 cleanup;任一 owner withdrawal 都会使当前 opened Attachment 和 socket epoch 失效,但错误来源和清理责任仍保持清楚。

多个插件共用一个业务列表

多个来源共同提供机器人、连接或账号时,先由一个业务插件拥有集合与查询 API,再由来源插件注册领域数据与操作。 工作台只打开聚合插件的一套页面。这样用户按业务对象工作,不需要在每个来源插件的管理页之间切换。

这与 Attachment 是两个问题:聚合集合由领域 API 完成;确有 provider 自有设置或选择器需要复用时,再使用 Attachment。 不要让浏览器枚举任意插件 API、拼接不同页面的缓存,或把每条记录发布为一个入口。

工作区的 projects/plugin-host/src/bot-lab/ 是使用真实 Workbench API 的模拟示例:

  • BotWorkspacePlugin 发布一个业务入口、参数化机器人文档和三个上下文面板。
  • Telegram、Discord、企业微信来源插件通过构造器依赖注册各自数据;停止时释放注册。
  • 列表使用来源与实体 ID 区分对象;修改通过聚合 API 返回冲突、来源不可用等领域结果。
  • 来源停止时,业务工作区仍存在,保留最后已知数据并显示不可用;不把来源停止当作整个页面 owner 撤回。

示例没有外部平台连接。它演示页面、来源和数据的所有权,实际产品仍需自己的权限、持久化和输入校验。

动态数据与生命周期

Collection、账号等是 API 返回的领域记录,不按每条记录创建 Workbench entry。列表分页,编辑器需要独立地址时声明一个参数化 route。 consumer 只保存稳定 ID;删除、冲突与 fallback 由双方领域 API 定义。实际渲染或发送消息仍走 constructor dependency,不依赖页面是否打开。

Provider API 与 consumer API 分别校验输入、授权、限制返回规模并清理资源。任一 owner withdrawal 都使当前 Attachment 失效;不能缓存打开的 root 供其他页面复用。 只想复用布局和按钮时,使用普通 React 组件与 props。

最后更新于

本页目录