> ## 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 插件开发

> 开发基于 React iframe remote_component 的插件视图，并通过 ViewExtensionProvider 和安全的宿主数据桥接访问服务端数据。

# 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']`，其中 `type` 为 `xpert.extension_view`，用于工具触发型场景。

iframe 不是可信 API 调用方，因此不会拿到：

* access token
* API base URL
* `assistantId`
* `hostId`
* `hostType`
* 权限信息

请求链路是：

```text theme={null}
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 之间的公开协议。

```ts theme={null}
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 的规则是：

```text theme={null}
<providerKey>__<manifestKey>
```

## 选择视图触发方式

`remote_component` 可以通过两种方式进入宿主界面：

* **Slot 枚举型**：宿主调用 `GET /api/view-hosts/:hostType/:hostId/slots/:slot/views` 获取某个 slot 下的 views，并按 manifest 渲染 remote component。
* **工具触发型**：工具调用结果携带 `_meta['xpertai/visualization']`，宿主只在对应工具被使用后打开这个视图。

如果你的插件视图是某个宿主页面的常驻扩展入口，优先使用 slot 枚举型。如果视图只应该随着某个工具调用结果出现，例如“打开指标管理”，再使用工具触发型。

## 可选：通过工具触发视图

工具只负责打开视图，不应该返回远程页面需要展示的完整数据。

```ts theme={null}
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。

```ts theme={null}
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 类。

```ts theme={null}
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` 关联响应。

```js theme={null}
(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：

```js theme={null}
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 })
}
```

宿主会转成：

```http theme={null}
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：

```http theme={null}
GET /api/view-hosts/agent/:assistantId/views/:viewKey/data
Authorization: Bearer <current-user-token>
organization-id: <assistant-organization-id>
```

provider 处理查询：

```ts theme={null}
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：

```js theme={null}
{
  type: 'data',
  requestId,
  data: {
    items: [],
    total: 0
  }
}
```

## 示例：保存单个指标

新建和编辑可以复用同一个表单。远程页面提交受控 action：

```js theme={null}
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)
  }
}
```

宿主会转成：

```http theme={null}
POST /agent-workbench/extensions/:assistantCode/views/:viewKey/actions/edit
Authorization: Bearer <current-user-token>
Content-Type: application/json
```

```json theme={null}
{
  "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 执行变更：

```ts theme={null}
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`，这样宿主可以清理缓存，远程页面也可以刷新当前查询。

```ts theme={null}
function success(en_US: string, zh_Hans: string): XpertViewActionResult {
  return {
    success: true,
    message: { en_US, zh_Hans },
    refresh: true
  }
}
```

## 参数选项

下拉框和依赖字段使用 `requestParameterOptions`：

```js theme={null}
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 实现：

```ts theme={null}
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

如果远程脚本放在插件源码目录下，需要确保包构建时复制到生产输出目录。

```json theme={null}
{
  "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"` 且没有直接网络凭据的情况下可以正常工作。
