> ## 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.

# 远程组件宿主桥接

> 使用受控消息协议连接工作台远程组件、宿主和视图提供器。

# 远程组件宿主桥接

宿主桥接连接远程组件 iframe、工作台宿主和插件视图提供器。它不是任意远程调用通道：每项能力都必须同时具有清单声明、宿主消息处理和对应的服务端实现。

```text theme={null}
远程组件
  -> postMessage
  -> 工作台宿主
  -> 已认证的视图扩展 API
  -> IXpertViewExtensionProvider
```

远程组件只发送业务意图。宿主负责解析当前用户、宿主、租户和组织上下文，并在调用提供器前校验视图、功能、权限和能力声明。

## 三层能力契约

| 层     | 所有者     | 作用                       |
| ----- | ------- | ------------------------ |
| 消息协议  | 宿主与远程组件 | 定义 iframe 请求、响应和生命周期消息   |
| 视图清单  | 插件视图提供器 | 声明允许使用的数据、操作、文件、客户端命令和事件 |
| 提供器方法 | 插件服务端   | 在可信服务端上下文中执行数据查询或业务操作    |

缺少任意一层时，该能力都不应执行。

## 消息信封

所有消息使用固定通道和协议版本：

```ts theme={null}
interface RemoteMessageEnvelope {
  channel: 'xpertai.remote_component'
  protocolVersion: 1
  instanceId?: string
  type: string
  requestId?: string
}
```

* iframe 加载完成后发送 `ready`。
* 宿主使用 `init` 返回 `instanceId`、视图清单、初始查询、语言、主题和调试配置。
* 后续消息必须携带匹配的 `instanceId`。
* 请求与响应使用同一个 `requestId`。
* iframe 只接受 `window.parent` 发出的消息；宿主只接受当前 iframe `contentWindow` 发出的消息。

## 支持的消息

宿主发送给 iframe：

| 消息                    | 用途                                |
| --------------------- | --------------------------------- |
| `init`                | 初始化清单、查询、语言、主题和调试状态               |
| `hostEvent`           | 转发与 `manifest.hostEvents` 匹配的宿主事件 |
| `data`                | `requestData` 的响应                 |
| `parameterOptions`    | 参数选项响应                            |
| `actionResult`        | JSON 操作响应                         |
| `fileActionResult`    | 文件操作响应                            |
| `fileAccessResult`    | 文件预览或下载授权响应                       |
| `clientCommandResult` | 客户端命令响应                           |
| `error`               | 与 `requestId` 对应的请求错误             |

iframe 发送给宿主：

| 消息                        | 用途                     |
| ------------------------- | ---------------------- |
| `ready`                   | 请求初始化                  |
| `resize`                  | 请求调整非固定工作台中的 iframe 高度 |
| `notify`                  | 请求显示宿主通知               |
| `requestData`             | 查询视图数据                 |
| `requestParameterOptions` | 查询动态参数选项               |
| `executeAction`           | 执行 JSON 操作             |
| `executeFileAction`       | 执行文件上传操作               |
| `requestFileAccess`       | 请求文件预览或下载授权            |
| `invokeClientCommand`     | 请求宿主执行界面操作             |

## 能力映射

| 能力      | 清单声明                             | iframe 消息                 | 服务端处理                     |
| ------- | -------------------------------- | ------------------------- | ------------------------- |
| 数据查询    | `dataSource`                     | `requestData`             | `getViewData`             |
| 参数选项    | `parameters[].optionSource`      | `requestParameterOptions` | `getViewParameterOptions` |
| JSON 操作 | `actions[].transport = 'json'`   | `executeAction`           | `executeViewAction`       |
| 文件操作    | `actions[].transport = 'file'`   | `executeFileAction`       | `executeViewFileAction`   |
| 文件访问    | `fileAccess.purposes`            | `requestFileAccess`       | `resolveViewFile`         |
| 客户端命令   | `clientCommands[]`               | `invokeClientCommand`     | 宿主命令注册表                   |
| 宿主事件    | `hostEvents.subscriptions[]`     | `hostEvent`               | 宿主管理                      |
| 远程入口    | `view.type = 'remote_component'` | 宿主管理                      | `getRemoteComponentEntry` |

## TypeScript 桥接客户端

将桥接代码集中在 `bridge.ts`，不要让业务组件直接拼接消息。

```ts theme={null}
const CHANNEL = 'xpertai.remote_component'
const VERSION = 1

type RemoteRequestType =
  | 'requestData'
  | 'requestParameterOptions'
  | 'executeAction'
  | 'executeFileAction'
  | 'requestFileAccess'
  | 'invokeClientCommand'

type RemoteOutboundType = RemoteRequestType | 'ready' | 'resize' | 'notify'

interface PendingRequest {
  resolve: (value: unknown) => void
  reject: (error: Error) => void
  timeout: number
}

interface BridgeMessage {
  channel?: unknown
  protocolVersion?: unknown
  instanceId?: unknown
  type?: unknown
  requestId?: unknown
  message?: unknown
  data?: unknown
  result?: unknown
  [key: string]: unknown
}

let instanceId: string | null = null
let sequence = 0
const pending = new Map<string, PendingRequest>()

export function installBridge(handlers: {
  onInit: (message: BridgeMessage) => void
  onHostEvent: (event: unknown) => void
}) {
  const listener = (event: MessageEvent) => {
    if (event.source !== window.parent || !isBridgeMessage(event.data)) return
    const message = event.data

    if (message.type === 'init') {
      instanceId = typeof message.instanceId === 'string'
        ? message.instanceId
        : null
      handlers.onInit(message)
      return
    }

    if (message.instanceId !== instanceId) return
    if (message.type === 'hostEvent') {
      handlers.onHostEvent(message.event)
      return
    }

    const requestId = typeof message.requestId === 'string'
      ? message.requestId
      : null
    const request = requestId ? pending.get(requestId) : undefined
    if (!request || !requestId) return

    pending.delete(requestId)
    window.clearTimeout(request.timeout)
    if (message.type === 'error') {
      request.reject(new Error(
        typeof message.message === 'string'
          ? message.message
          : 'remote_request_failed'
      ))
    } else {
      request.resolve(message.data ?? message.result)
    }
  }

  window.addEventListener('message', listener)
  post('ready')
  return () => {
    window.removeEventListener('message', listener)
    for (const request of pending.values()) {
      window.clearTimeout(request.timeout)
      request.reject(new Error('remote_bridge_disposed'))
    }
    pending.clear()
  }
}

export function requestHost(
  type: RemoteRequestType,
  body: Record<string, unknown>
) {
  const requestId = `${Date.now()}-${++sequence}`
  return new Promise<unknown>((resolve, reject) => {
    const timeout = window.setTimeout(() => {
      pending.delete(requestId)
      reject(new Error('remote_request_timeout'))
    }, 30_000)
    pending.set(requestId, { resolve, reject, timeout })
    post(type, { requestId, ...body })
  })
}

function post(type: RemoteOutboundType, body: Record<string, unknown> = {}) {
  window.parent.postMessage({
    channel: CHANNEL,
    protocolVersion: VERSION,
    instanceId,
    type,
    ...body
  }, '*')
}

function isBridgeMessage(value: unknown): value is BridgeMessage {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false
  const message = value as BridgeMessage
  return message.channel === CHANNEL &&
    message.protocolVersion === VERSION &&
    typeof message.type === 'string'
}
```

业务代码可以在此基础上封装 `requestData()`、`executeAction()` 和 `invokeClientCommand()` 等具名方法。桥接层返回稳定错误代码，由 UI 多语言层转换成用户可见文案。

## 查询数据

```ts theme={null}
const result = await requestHost('requestData', {
  query: {
    page: 1,
    pageSize: 20,
    search,
    parameters: {
      table: 'contracts',
      status
    }
  }
})
```

`XpertViewQuery.parameters` 只支持标量或标量数组。复杂筛选条件应序列化为明确的 JSON 字符串参数，并由提供器安全解析。

大型表格应使用服务端分页。远程组件不要一次把所有记录加载到 iframe。

## 执行操作

普通业务变更使用 JSON 操作：

```ts theme={null}
await requestHost('executeAction', {
  actionKey: 'approve_contract',
  targetId: contractId,
  input: { comment },
  parameters: { projectId }
})
```

`actionKey` 必须存在于 `manifest.actions`，且传输方式必须匹配。提供器仍需校验目标对象、当前用户和租户/组织范围。

文件上传使用 `executeFileAction`，不要把二进制内容或 Base64 放进 JSON 操作。文件预览与下载使用 `requestFileAccess`，由宿主换取有时效的访问授权。

## 客户端命令

客户端命令用于宿主本地 UI 行为，不调用插件提供器。例如发送 Assistant 消息、设置 Assistant 上下文、打开文件或导航到另一工作台视图。

它是一个封闭的三方契约：

1. 源视图在 `manifest.clientCommands` 中声明命令键。
2. 当前宿主注册同名处理器。
3. 远程组件通过 `invokeClientCommand` 调用，并处理结构化失败结果。

```ts theme={null}
import {
  WORKBENCH_NAVIGATION_OPEN_COMMAND,
  WORKBENCH_EXTENSION_VIEW_TARGET
} from '@xpert-ai/contracts'

await requestHost('invokeClientCommand', {
  commandKey: WORKBENCH_NAVIGATION_OPEN_COMMAND,
  payload: {
    target: WORKBENCH_EXTENSION_VIEW_TARGET,
    viewKey: 'contract_review__history',
    selectionId: contractId
  }
})
```

不要从 iframe 直接修改顶层窗口地址。

## 宿主事件

远程组件可以订阅宿主归一化后的事件，例如 Agent 中间件工具完成：

```ts theme={null}
hostEvents: {
  subscriptions: [
    {
      key: 'contract-mutated',
      event: 'assistant.tool.completed',
      filter: {
        sources: ['chatkit'],
        toolNames: ['contract_update', 'contract_approve']
      },
      action: { type: 'forward', debounceMs: 800 }
    }
  ]
}
```

声明式视图通常使用 `refresh`。远程组件优先使用 `forward`，根据业务 ID 只刷新受影响的数据区域，并在本地存在未保存编辑时避免静默覆盖。

## 初始化、主题和多语言

以 `init.locale` 为准，在入口处一次性归一化为 BCP 47 语言标签，例如 `en-US`、`zh-Hans` 和 `zh-Hant`。不要在业务组件中根据语言直接选择文本。

应用 `init.theme.tokens` 后，调用 `installShadcnThemeVars()` 安装语义主题变量。调试日志由 `init.debug.enabled` 控制；不要根据 URL、主机名或平台身份猜测开发环境。

不要依赖 `localStorage` 或 `sessionStorage`。临时状态保存在 React 状态中，持久状态通过宿主桥接写入服务端。

## 安全检查

* 不向 iframe 传递访问令牌、API 地址、Assistant ID、租户 ID 或组织 ID。
* 不让 iframe 自行选择 `hostType` 或 `hostId`。
* 不执行清单未声明的操作、文件能力或客户端命令。
* 不在日志中输出令牌、文件内容、完整业务快照或个人敏感信息。
* 提供器对每次读取和变更重新执行授权与租户/组织隔离。
* 对消息来源、协议版本、实例 ID 和请求 ID 添加自动化测试。

返回[工作台远程组件](./remote-component)查看完整开发流程。
