Documentation Index
Fetch the complete documentation index at: https://docs.xpertai.cn/llms.txt
Use this file to discover all available pages before exploring further.
Remote Component 插件开发
本文说明如何开发一套新的 Assistant Workbench remote_component 类型插件。remote_component 是 View Extension 的一种视图 schema,不限定触发方式:它既可以作为普通 view slot 被宿主枚举展示,也可以由工具结果中的 xpert.extension_view 显式触发。该模式适合插件需要完整自定义 UI,而不是只使用声明式 table、list、stats、detail renderer 的场景。
一套 remote component 插件至少由两部分组成;如果需要让 Agent 在对话中按需打开视图,再额外提供工具入口:
- ViewExtensionProvider:提供 manifest、data、parameter options、actions 和远程组件 HTML entry。
- 远程 React 页面:运行在 sandbox iframe 中,通过
postMessage 与宿主通信。
- 可选工具入口:返回
_meta['xpertai/visualization'],其中 type 为 xpert.extension_view,用于工具触发型场景。
iframe 不是可信 API 调用方,因此不会拿到:
- access token
- API base URL
assistantId
hostId
hostType
- 权限信息
请求链路是:
remote iframe
-> postMessage(requestData / requestParameterOptions / executeAction)
-> data-xpert Workbench 宿主页面
-> authenticatedFetch(...)
-> data-xpert API proxy
-> xpert-pro ViewExtension API
-> 插件 ViewExtensionProvider
认证和授权发生在 data-xpert 与 xpert-pro:
- data-xpert Web 通过
authenticatedFetch 携带当前用户 OIDC token。
- data-xpert API 校验当前用户,并根据
assistantCode 解析 effective assistant。
- data-xpert API 固定转发到
hostType=agent 和 hostId=<assistant.assistantId>。
- xpert-pro 在调用 provider 前校验 view host 访问权限、manifest 可见性和 action 权限。
定义稳定 Key
先定义稳定 key。它们会成为 view host、Workbench canvas、可选工具结果和 provider 之间的公开协议。
export const PROVIDER_KEY = 'my_metric_management'
export const VIEW_KEY = 'metrics'
export const PUBLIC_VIEW_KEY = 'my_metric_management__metrics'
export const REMOTE_ENTRY_KEY = 'metric-management'
公开 view key 的规则是:
<providerKey>__<manifestKey>
选择视图触发方式
remote_component 可以通过两种方式进入宿主界面:
- Slot 枚举型:宿主调用
GET /api/view-hosts/:hostType/:hostId/slots/:slot/views 获取某个 slot 下的 views,并按 manifest 渲染 remote component。
- 工具触发型:工具调用结果携带
_meta['xpertai/visualization'],宿主只在对应工具被使用后打开这个视图。
如果你的插件视图是某个宿主页面的常驻扩展入口,优先使用 slot 枚举型。如果视图只应该随着某个工具调用结果出现,例如“打开指标管理”,再使用工具触发型。
可选:通过工具触发视图
工具只负责打开视图,不应该返回远程页面需要展示的完整数据。
return {
content: [
{
type: 'text',
text: '正在打开指标管理。'
}
],
_meta: {
'xpertai/visualization': {
type: 'xpert.extension_view',
title: '指标管理',
slotKey: 'tool:metric-management',
parameterKey: `metrics:${projectId ?? 'all'}:${modelId ?? 'all'}`,
renderMode: 'replace',
payload: {
version: 1,
viewKey: 'my_metric_management__metrics',
parameters: {
projectId,
modelId
},
initialQuery: {
page: 1,
pageSize: 20
}
},
metadata: {
source: 'mcp-tool',
sourceId: 'metric_management_open'
}
}
}
}
不要在工具结果中放 hostId、hostType、API URL、token 或权限信息。真实宿主由 data-xpert 后端根据当前会话重新解析。
提供 Remote Component Manifest
通过 @ViewExtensionProvider(providerKey) 注册 provider,并在 agent.workbench.main slot 中返回 remote_component + react + iframe manifest。
import {
IXpertViewExtensionProvider,
ViewExtensionProvider
} from '@xpert-ai/plugin-sdk'
import type {
XpertExtensionViewManifest,
XpertResolvedViewHostContext
} from '@xpert-ai/contracts'
@ViewExtensionProvider('my_metric_management')
export class MetricManagementViewProvider
implements IXpertViewExtensionProvider
{
supports(context: XpertResolvedViewHostContext) {
return context.hostType === 'agent'
}
getViewManifests(
_context: XpertResolvedViewHostContext,
slot: string
): XpertExtensionViewManifest[] {
if (slot !== 'agent.workbench.main') {
return []
}
return [
{
key: 'metrics',
title: { en_US: 'Metric Management', zh_Hans: '指标管理' },
hostType: 'agent',
slot,
refreshable: true,
parameters: [
{
key: 'projectId',
label: { en_US: 'Project', zh_Hans: '项目' },
required: true,
type: 'string',
optionSource: {
mode: 'provider',
searchable: true,
preload: true
}
},
{
key: 'modelId',
label: { en_US: 'Model', zh_Hans: '模型' },
type: 'string',
optionSource: {
mode: 'provider',
searchable: true,
dependsOn: ['projectId']
}
}
],
view: {
type: 'remote_component',
runtime: 'react',
protocolVersion: 1,
component: {
isolation: 'iframe',
entry: 'metric-management'
},
dataSource: {
mode: 'platform'
}
},
dataSource: {
mode: 'platform',
querySchema: {
supportsPagination: true,
supportsSearch: true,
supportsSort: true,
supportsParameters: true,
defaultPageSize: 20
},
cache: {
enabled: false
}
},
actions: [
{
key: 'create',
label: { en_US: 'Create', zh_Hans: '新建' },
placement: 'toolbar',
actionType: 'invoke'
},
{
key: 'edit',
label: { en_US: 'Edit', zh_Hans: '编辑' },
placement: 'row',
actionType: 'invoke',
inputDefaults: 'target'
}
]
}
]
}
}
如果变更类 action 需要比读取视图更高的权限,应在 action 上声明 permissions。
返回 iframe HTML Entry
provider 需要为 iframe 返回单文件 HTML。推荐使用 @xpert-ai/plugin-sdk 的 renderRemoteReactIframeHtml(),让多个插件共享同一套 shell、reset、主题变量和 .xui-* UI 类。
import { createRequire } from 'module'
import { readFile } from 'fs/promises'
import { dirname, join } from 'path'
import {
IXpertViewExtensionProvider,
renderRemoteReactIframeHtml
} from '@xpert-ai/plugin-sdk'
import type {
XpertRemoteComponentEntry,
XpertRemoteComponentViewSchema,
XpertResolvedViewHostContext
} from '@xpert-ai/contracts'
const requireFromHere = createRequire(__filename)
async function readPackageFile(packageName: string, relativePath: string) {
const packageRoot = dirname(
requireFromHere.resolve(`${packageName}/package.json`)
)
return readFile(join(packageRoot, relativePath), 'utf8')
}
export class MetricManagementViewProvider
implements IXpertViewExtensionProvider
{
async getRemoteComponentEntry(
_context: XpertResolvedViewHostContext,
viewKey: string,
component: XpertRemoteComponentViewSchema['component']
): Promise<XpertRemoteComponentEntry> {
if (viewKey !== 'metrics' || component.entry !== 'metric-management') {
return {
html: '<!doctype html><html><body>Unsupported entry.</body></html>',
contentType: 'text/html; charset=utf-8'
}
}
const appScript = await readFile(
join(__dirname, 'remote-components', 'metric-management', 'app.js'),
'utf8'
)
const react = await readPackageFile('react', 'umd/react.production.min.js')
const reactDom = await readPackageFile(
'react-dom',
'umd/react-dom.production.min.js'
)
return {
html: renderRemoteReactIframeHtml({
title: '指标管理',
lang: 'zh-Hans',
reactUmd: react,
reactDomUmd: reactDom,
appScript
}),
contentType: 'text/html; charset=utf-8'
}
}
}
entry 是 provider-local key,不是浏览器 URL。ViewExtension API 会拒绝绝对 URL、..、反斜杠等不安全值。
实现 Remote Bridge Client
iframe 页面通过 message 与父窗口通信,并用 requestId 关联响应。
(function () {
const CHANNEL = 'xpertai.remote_component'
const VERSION = 1
let instanceId = null
let sequence = 0
const pending = new Map()
function post(type, body) {
if (!instanceId && type !== 'ready') return
parent.postMessage(
{
channel: CHANNEL,
protocolVersion: VERSION,
instanceId,
type,
...(body || {})
},
'*'
)
}
function request(type, body) {
const requestId = String(++sequence)
post(type, { requestId, ...(body || {}) })
return new Promise((resolve, reject) => {
pending.set(requestId, { resolve, reject })
})
}
window.addEventListener('message', (event) => {
const message = event.data
if (
!message ||
message.channel !== CHANNEL ||
message.protocolVersion !== VERSION
) {
return
}
if (message.type === 'init') {
instanceId = message.instanceId
window.startApp({
manifest: message.manifest,
payload: message.payload,
initialQuery: message.initialQuery || {},
locale: message.locale,
theme: message.theme
})
return
}
if (message.instanceId !== instanceId) return
if (message.requestId && pending.has(message.requestId)) {
const item = pending.get(message.requestId)
pending.delete(message.requestId)
if (message.type === 'error') {
item.reject(new Error(message.message || 'Remote request failed'))
} else {
item.resolve(message)
}
}
})
window.xpertRemote = { request }
post('ready')
})()
Workbench 宿主只接受来自当前 iframe contentWindow 且 instanceId 匹配的消息。
示例:获取指标列表
远程页面不直接调用 fetch,而是请求宿主读取 view data:
async function loadData(query) {
const response = await window.xpertRemote.request('requestData', {
query: {
page: query.page,
pageSize: query.pageSize,
search: query.search,
parameters: {
projectId: query.parameters.projectId,
modelId: query.parameters.modelId
}
}
})
setData(response.data || { items: [], total: 0 })
}
宿主会转成:
GET /agent-workbench/extensions/:assistantCode/views/:viewKey/data
?page=1
&pageSize=20
&search=收入
¶meters={"projectId":"project-1","modelId":"model-1"}
Authorization: Bearer <current-user-token>
data-xpert API 解析 assistant 后转发到 xpert-pro:
GET /api/view-hosts/agent/:assistantId/views/:viewKey/data
Authorization: Bearer <current-user-token>
organization-id: <assistant-organization-id>
provider 处理查询:
async getViewData(
_context: XpertResolvedViewHostContext,
viewKey: string,
query: XpertViewQuery
): Promise<XpertViewDataResult> {
if (viewKey !== 'metrics') {
return {}
}
const projectId = getStringParameter(query.parameters, 'projectId')
if (!projectId) {
return {
items: [],
total: 0,
meta: {
reason: 'project_required'
}
}
}
const modelId = getStringParameter(query.parameters, 'modelId')
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const result = await this.indicatorService.findMy({
where: buildIndicatorWhere(projectId, modelId, query.search),
relations: ['model'],
take: pageSize,
skip: (page - 1) * pageSize,
order: buildIndicatorOrder(query.sortBy, query.sortDirection)
})
return {
items: result.items.map(toMetricRow),
total: result.total
}
}
结果会通过 message 回到 iframe:
{
type: 'data',
requestId,
data: {
items: [],
total: 0
}
}
示例:保存单个指标
新建和编辑可以复用同一个表单。远程页面提交受控 action:
async function saveMetric(mode, row, form, query) {
const input = {
code: form.code.trim(),
name: form.name.trim(),
type: form.type,
modelId: form.modelId || undefined,
entity: form.entity.trim() || undefined,
business: form.business.trim() || undefined,
unit: form.unit.trim() || undefined,
visible: form.visible
}
const response = await window.xpertRemote.request('executeAction', {
actionKey: mode === 'create' ? 'create' : 'edit',
targetId: row && row.id,
input,
parameters: query.parameters
})
if (response.result?.refresh) {
await loadData(query)
}
}
宿主会转成:
POST /agent-workbench/extensions/:assistantCode/views/:viewKey/actions/edit
Authorization: Bearer <current-user-token>
Content-Type: application/json
{
"targetId": "metric-1",
"input": {
"code": "gmv",
"name": "GMV",
"type": "BASIC",
"modelId": "model-1",
"business": "订单总成交额",
"unit": "元",
"visible": true
},
"parameters": {
"projectId": "project-1",
"modelId": "model-1"
}
}
provider 执行变更:
async executeViewAction(
_context: XpertResolvedViewHostContext,
viewKey: string,
actionKey: string,
request: XpertViewActionRequest
): Promise<XpertViewActionResult> {
if (viewKey !== 'metrics') {
return failure('Unsupported view', '不支持的视图')
}
if (actionKey === 'create') {
const projectId = getStringParameter(request.parameters, 'projectId')
if (!projectId) {
return failure('Project is required', '请先选择项目')
}
await this.indicatorService.createDraft(
toIndicatorDraft(request.input),
projectId
)
return success('Metric created', '指标已创建')
}
const targetId = request.targetId?.trim()
if (!targetId) {
return failure('Target metric is required', '缺少目标指标')
}
if (actionKey === 'edit') {
await this.indicatorService.updateDraft(
targetId,
toIndicatorDraft(request.input)
)
return success('Metric updated', '指标已更新')
}
return failure('Unsupported action', '不支持的操作')
}
变更类 action 成功后应返回 refresh: true,这样宿主可以清理缓存,远程页面也可以刷新当前查询。
function success(en_US: string, zh_Hans: string): XpertViewActionResult {
return {
success: true,
message: { en_US, zh_Hans },
refresh: true
}
}
参数选项
下拉框和依赖字段使用 requestParameterOptions:
const projects = await window.xpertRemote.request('requestParameterOptions', {
parameterKey: 'projectId',
parameters: query.parameters
})
const models = await window.xpertRemote.request('requestParameterOptions', {
parameterKey: 'modelId',
parameters: {
...query.parameters,
projectId
}
})
provider 实现:
async getViewParameterOptions(
_context: XpertResolvedViewHostContext,
viewKey: string,
parameterKey: string,
query: XpertViewParameterOptionsQuery
): Promise<XpertViewParameterOptionsResult> {
if (viewKey !== 'metrics') {
return { items: [] }
}
if (parameterKey === 'projectId') {
return {
items: projects.map((project) => ({
value: project.id,
label: project.name ?? project.id
}))
}
}
if (parameterKey === 'modelId') {
const projectId = getStringParameter(query.parameters, 'projectId')
return {
items: modelsFor(projectId).map((model) => ({
value: model.id,
label: model.name ?? model.id
}))
}
}
return { items: [] }
}
remote component 应使用 renderRemoteReactIframeHtml() 提供的共享 .xui-* 类:
.xui-app
.xui-toolbar
.xui-control
.xui-input
.xui-button
.xui-button-primary
.xui-table
.xui-modal
.xui-notice
宿主会发送 init.theme.tokens,helper 会把它们映射为 --xui-* CSS variables。插件 CSS 应只补充业务布局,不要重新定义基础控件视觉。
构建 Assets
如果远程脚本放在插件源码目录下,需要确保包构建时复制到生产输出目录。
{
"glob": "**/*.{html,css,js}",
"input": "packages/analytics/src/plugins",
"output": "src/plugins"
}
测试清单
上线前建议验证:
- 工具返回合法
xpert.extension_view payload。
- 公开 view key 符合
<providerKey>__<manifestKey>。
- manifest 返回
remote_component + react + iframe。
getRemoteComponentEntry 返回 HTML,且不包含 token、API URL、assistantId 或 hostId。
- 列表加载通过
requestData。
- 保存通过
executeAction。
- 参数 options 支持独立字段和依赖字段。
- 无权限用户无法访问 view host。
- 必要时变更类 action 拥有比读取视图更严格的权限。
- iframe 在
sandbox="allow-scripts" 且没有直接网络凭据的情况下可以正常工作。