跳转到主要内容

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 在对话中按需打开视图,再额外提供工具入口:
  1. ViewExtensionProvider:提供 manifest、data、parameter options、actions 和远程组件 HTML entry。
  2. 远程 React 页面:运行在 sandbox iframe 中,通过 postMessage 与宿主通信。
  3. 可选工具入口:返回 _meta['xpertai/visualization'],其中 typexpert.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=agenthostId=<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'
      }
    }
  }
}
不要在工具结果中放 hostIdhostType、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-sdkrenderRemoteReactIframeHtml(),让多个插件共享同一套 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 contentWindowinstanceId 匹配的消息。

示例:获取指标列表

远程页面不直接调用 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=收入
  &parameters={"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、assistantIdhostId
  • 列表加载通过 requestData
  • 保存通过 executeAction
  • 参数 options 支持独立字段和依赖字段。
  • 无权限用户无法访问 view host。
  • 必要时变更类 action 拥有比读取视图更严格的权限。
  • iframe 在 sandbox="allow-scripts" 且没有直接网络凭据的情况下可以正常工作。