构建您的 ChatGPT UI – Apps SDK | OpenAI 开发者
来源: https://developers.openai.com/apps-sdk/build/chatgpt-ui 抓取时间: 2026-07-21 16:19:18
概述
UI 组件将来自您的 MCP 服务器的结构化工具结果转换为用户友好的 UI。您的组件在 ChatGPT 的 iframe 中运行,通过 MCP Apps 桥(基于 postMessage 的 JSON-RPC)与主机通信,并与对话内联渲染。
这是为 ChatGPT Apps 构建的 UI 架构,后来作为 MCP Apps 标准化,因此您可以构建一次并在兼容 MCP Apps 的主机上运行您的 UI。
ChatGPT 继续支持 window.openai 以实现 Apps SDK 兼容性和可选的 ChatGPT 扩展。
您还可以查看 GitHub 上的示例存储库。
组件库
使用 apps-sdk-ui 中的可选 UI 工具包,获取与 ChatGPT 容器匹配的现成按钮、卡片、输入控件和布局原语。当您想要一致的样式而无需重建基础组件时,它可以节省时间。
使用 MCP Apps 桥(推荐)
ChatGPT 实现了开放的 MCP Apps 应用程序接口标准。对于新应用,默认使用桥:
- 传输:基于
postMessage的 JSON-RPC 2.0。 - 工具 I/O:
ui/notifications/tool-input和ui/notifications/tool-result。 - 工具调用:
tools/call。 - 消息 + 上下文:
ui/message和ui/update-model-context。
有关高级概述和 Apps SDK API 的映射指南,请参见 ChatGPT 中的 MCP Apps 兼容性。
接收工具输入和结果
ChatGPT 将工具输入和结果作为 JSON-RPC 通知发送到您的 iframe。例如,工具结果以 ui/notifications/tool-result 形式到达:
{
"jsonrpc": "2.0",
"method": "ui/notifications/tool-result",
"params": {
"content": [],
"structuredContent": { "tasks": [] }
}
}
监听通知并从 structuredContent 重新渲染:
window.addEventListener(
"message",
(event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.method !== "ui/notifications/tool-result") return;
const toolResult = message.params;
const data = toolResult?.structuredContent;
// Update UI from `data`.
},
{ passive: true }
);
如果工具需要用户批准,不要假设工具输入在第一次渲染时可用。ChatGPT 可能会等待填充 window.openai.toolInput 并仅在用户批准调用后才发送 ui/notifications/tool-input,因此小部件应订阅生命周期通知并将缺少初始输入视为正常状态。
从 UI 调用工具
要直接从 UI 调用工具,请发送 tools/call 的 JSON-RPC 请求。确保工具在其描述符中可用于 UI(应用程序)。默认情况下,工具对模型和 UI 都可用;需要时使用 _meta.ui.visibility 来限制。
有关使用 postMessage 的最小请求/响应实现,请参见快速入门:快速入门。
发送后续消息
使用 ui/message 要求主机发布消息:
window.parent.postMessage(
{
jsonrpc: "2.0",
method: "ui/message",
params: {
role: "user",
content: [
{ type: "text", text: "Draft a tasting itinerary for my picks." },
],
},
},
"*"
);
更新模型可见的上下文
当 UI 状态以模型应该看到的方式变化时,调用 ui/update-model-context:
// Requires a JSON-RPC request/response helper.
await rpcRequest("ui/update-model-context", {
content: [{ type: "text", text: "User selected 3 items." }],
});
将数据处理与 UI 渲染分离
解耦模式
如果您将小部件模板附加到每个工具调用,ChatGPT 可能会过于频繁地重新渲染您的 iframe。更好的模式是将数据处理工具与渲染工具分开:
- 数据工具 获取、计算或变更数据,仅返回工具结果。
- 渲染工具 接受最终数据并返回小部件模板。
这允许模型在选择向用户渲染 UI 之前将其智能应用于获取的数据,从而更有可能实现用户明确表达的目标。
当前的 Apps SDK 设计已经支持这一点。
实际上,许多应用程序使用这种拆分:
- 搜索/获取工具(数据优先): 返回 ID 加上元数据,不附加小部件模板。
- 渲染工具(例如
render_listings_widget): 接受准备好的 ID 列表并渲染小部件。
在 ChatGPT 中,只有渲染工具应包含 _meta["openai/outputTemplate"]。为了更广泛的 MCP Apps 兼容性,还要在渲染工具上设置 _meta.ui.resourceUri。
解耦的调用流程
推荐的调用流程:
- 模型调用数据工具(例如
roll_dice)。 - 模型从数据工具接收
structuredContent。 - 模型使用该数据调用渲染工具。
- 小部件使用最终的、模型检查的上下文渲染一次。
示例:房地产后续查询
假设您的应用程序显示列表卡片和地图,但您的后端 search 工具仅支持广泛的过滤器(城市、价格、床、浴),不能按学区过滤。
如果用户问"这些中哪些在里士满小学学区?",解耦会有所帮助:
search广泛运行并返回候选列表 ID 加上元数据。- 模型为后续问题细化候选集。
- 模型仅使用过滤后的 ID 调用
render_listings_widget。 - 小部件渲染最终的过滤集。
最佳实践:
- 保持数据工具可重用。返回完整的
structuredContent以进行链式操作。 - 保持渲染工具专注于展示。不要将业务逻辑混入渲染处理程序。
- 在渲染工具描述中说明依赖关系(例如"始终先调用
roll_dice")。 - 保持重新运行是有意的。让 UI 直接调用数据工具进行本地交互,如"重新掷骰",而无需重新挂载小部件。
解耦示例
示例(解耦的骰子工具):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod/v3";
const TEMPLATE_URI = "ui://widget/dice.html";
const server = new McpServer(
{ name: "Decoupled dice", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// The widget only renders toolOutput.
// Re-roll calls the data tool directly to avoid remounting the widget.
const widgetHtml = `
<div style="font-family: system-ui; padding: 8px;">
<div style="font-size: 20px; margin-bottom: 6px;">
Result: <span id="out">—</span>
</div>
<button id="reroll">Re-roll</button>
</div>
<script>
const outputEl = document.getElementById("out");
const rerollButton = document.getElementById("reroll");
function render(result) {
outputEl.textContent = String(result?.value ?? "—");
}
render(window.openai?.toolOutput);
rerollButton.onclick = async () => {
const current = window.openai?.toolOutput;
const sides = current?.sides ?? window.openai?.toolInput?.sides ?? 6;
const next = await window.openai?.callTool?.("roll_dice", { sides });
if (next?.structuredContent) {
render(next.structuredContent);
}
};
window.addEventListener(
"openai:set_globals",
(event) => {
render(event.detail?.globals?.toolOutput ?? window.openai?.toolOutput);
},
{ passive: true }
);
</script>
`.trim();
server.registerResource("dice-widget", TEMPLATE_URI, {}, async () => ({
contents: [
{
uri: TEMPLATE_URI,
mimeType: "text/html;profile=mcp-app",
text: widgetHtml,
_meta: { ui: { prefersBorder: true } },
},
],
}));
// 1) Data tool: no output template, returns chainable structuredContent.
server.registerTool(
"roll_dice",
{
title: "Roll dice",
description: "Roll an N-sided die and return { sides, value }.",
inputSchema: { sides: z.number().int().min(2) },
outputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
_meta: {
"openai/toolInvocation/invoking": "Rolling…",
"openai/toolInvocation/invoked": "Rolled.",
},
},
async ({ sides }) => {
const value = 1 + Math.floor(Math.random() * sides);
return {
structuredContent: { sides, value },
content: [{ type: "text", text: `Rolled ${value} on ${sides} sides.` }],
};
}
);
// 2) Render tool: owns the template and requires data from roll_dice.
server.registerTool(
"render_dice_widget",
{
title: "Render dice widget",
description:
"Render the dice widget from roll data. First call roll_dice, then pass its sides and value to this tool.",
inputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
outputSchema: {
sides: z.number().int().min(2),
value: z.number().int().min(1),
},
_meta: {
ui: { resourceUri: TEMPLATE_URI },
"openai/outputTemplate": TEMPLATE_URI,
"openai/toolInvocation/invoking": "Rendering…",
"openai/toolInvocation/invoked": "Rendered.",
},
},
async ({ sides, value }) => ({
structuredContent: { sides, value },
content: [
{
type: "text",
text: `Showing a ${sides}-sided roll: ${value}.`,
},
],
})
);
export default server;
理解 window.openai API
ChatGPT 提供 window.openai 作为 Apps SDK 兼容层和一些仅 ChatGPT 的功能。OpenAI 扩展是可选的——当它们在 ChatGPT 中添加实质性价值时使用它们,但不要依赖它们来获得基线 MCP Apps 兼容性。
有关完整的 API 参考,请参见 Apps SDK 参考。
useOpenAiGlobal 帮助器
许多 Apps SDK 项目将 window.openai 访问包装在小的帮助器函数中,以便视图保持可测试。此示例帮助器监听主机的 openai:set_globals 事件,并允许 React 组件订阅单个全局值:
export function useOpenAiGlobal<K extends keyof WebplusGlobals>(
key: K
): WebplusGlobals[K] {
return useSyncExternalStore(
(onChange) => {
const handleSetGlobal = (event: SetGlobalsEvent) => {
const value = event.detail.globals[key];
if (value === undefined) {
return;
}
onChange();
};
window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
passive: true,
});
return () => {
window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
};
},
() => window.openai[key]
);
}
从小部件上传文件(ChatGPT 扩展)
使用 window.openai.uploadFile(file, { library?: boolean }) 上传用户选择的文件并接收 fileId。当上传也应保存到用户的 ChatGPT 文件库(如果该库对当前用户可用)时,传递 { library: true }。
function FileUploadInput() {
return (
<input
type="file"
onChange={async (event) => {
const file = event.currentTarget.files?.[0];
if (!file || !window.openai?.uploadFile) {
return;
}
const { fileId } = await window.openai.uploadFile(file, {
library: true,
});
console.log("Uploaded fileId:", fileId);
}}
/>
);
}
重用 ChatGPT 文件库中的文件(ChatGPT 扩展)
当用户应该能够选择他们已经上传到 ChatGPT 的文件而不是再次上传时,使用 window.openai.selectFiles()。ChatGPT 文件库并非对每个用户或环境都可用,因此在依赖此帮助器之前请进行功能检测。返回的文件 ID 已经为当前应用程序授权。
async function pickExistingFiles() {
if (!window.openai?.selectFiles) {
return [];
}
const files = await window.openai.selectFiles();
console.log(files);
// [{ fileId, fileName, mimeType }]
return files;
}
功能检测 window.openai.selectFiles,当当前环境或用户无法访问库选择器时,回退到 window.openai.uploadFile。
在小部件中下载文件(ChatGPT 扩展)
使用 window.openai.getFileDownloadUrl({ fileId }) 来检索小部件上传的文件、从文件库中选择的文件、通过工具输入文件参数接收的文件或从工具结果文件引用接收的文件的临时 URL。
const { downloadUrl } = await window.openai.getFileDownloadUrl({ fileId });
imageElement.src = downloadUrl;
工具文件引用使用蛇形命名字段:
{
"download_url": "https://...",
"file_id": "file_...",
"mime_type": "image/png",
"file_name": "input.png"
}
在调用 window.openai.getFileDownloadUrl({ fileId }) 时,使用该对象中的 file_id 作为 fileId。download_url 是临时的,应仅用于当前操作。
关闭小部件(ChatGPT 扩展)
您可以通过两种方式关闭小部件:通过调用 window.openai.requestClose() 从 UI 关闭,或者从服务器端通过让您的工具响应设置 metadata.openai/closeWidget: true 来关闭,这会指示主机在响应到达时隐藏小部件:
{
"role": "tool",
"tool_call_id": "abc123",
"content": "...",
"metadata": {
"_meta": {
"ui": {
"csp": {
"connectDomains": ["https://api.myapp.example.com"],
"resourceDomains": ["https://persistent.oaistatic.com"],
"frameDomains": ["https://widgets.example.com"]
}
}
},
"openai/closeWidget": true,
"openai/widgetCSP": {
"redirect_domains": ["https://checkout.example.com"]
},
"openai/widgetDomain": "https://myapp.example.com"
}
}
注意:默认情况下,小部件无法渲染子框架。设置 _meta.ui.csp.frameDomains 会放宽此限制,并允许您的小部件从这些来源嵌入 iframe。使用 iframe 嵌入的应用程序面临更严格的审查,并且通常无法通过广泛分发审查,除非 iframe 内容对用例是核心的。
如果您希望 window.openai.openExternal 将用户发送到外部流程(如结账)并启用返回同一对话的链接,请将目标来源添加到 openai/widgetCSP 的 redirect_domains 下。然后 ChatGPT 将跳过安全链接模式,并将 redirectUrl 查询参数附加到目标,以便您可以将用户路由回 ChatGPT。
小部件会话 ID
主机在工具响应元数据中包含每个小部件的标识符作为 openai/widgetSessionId。使用它来关联同一小部件实例在保持挂载期间的工具调用或日志。
请求替代布局(ChatGPT 扩展)
如果 UI 需要更多空间——如地图、表格或嵌入式编辑器——请主机更改容器。window.openai.requestDisplayMode 协商内联、画中画或全屏显示。
await window.openai?.requestDisplayMode({ mode: "fullscreen" });
// Note: on mobile, PiP may be coerced to fullscreen
打开模态框(ChatGPT 扩展)
使用 window.openai.requestModal 打开主机控制的模态框。您可以通过提供在 MCP 服务器上用 registerResource 注册的模板 URI,从同一应用程序传递不同的 UI 模板,或者省略 template 以打开当前的模板。
await window.openai.requestModal({
template: "ui://widget/checkout.html",
});
使用主机支持的导航
Skybridge(沙箱运行时)将 iframe 的历史记录镜像到 ChatGPT 的 UI 中。使用标准路由 API(如 React Router),主机将导航控件与您的组件保持同步。
路由器设置(React Router 的 BrowserRouter):
export default function PizzaListRouter() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<PizzaListApp />}>
<Route path="place/:placeId" element={<PizzaListApp />} />
</Route>
</Routes>
</BrowserRouter>
);
}
编程导航:
const navigate = useNavigate();
function openDetails(placeId: string) {
navigate(`place/${placeId}`, { replace: false });
}
function closeDetails() {
navigate("..", { replace: true });
}
搭建组件项目
既然您了解了 MCP Apps 桥(以及可选的 ChatGPT 扩展),是时候搭建您的组件项目了。
作为最佳实践,保持组件代码与服务器逻辑分离。常见的布局是:
app/
server/ # MCP server (Python or Node)
web/ # Component bundle source
package.json
tsconfig.json
src/component.tsx
dist/component.js # Build output
创建项目并安装依赖项(推荐 Node 18+):
cd app/web
npm init -y
npm install react@^18 react-dom@^18
npm install -D typescript esbuild
如果您的组件需要拖放、图表或其他库,请立即添加它们。保持依赖集精简以减少包大小。
编写 React 组件
您的入口文件应将组件挂载到 root 元素中,并从通过 MCP Apps 桥传递的最新工具结果(例如 ui/notifications/tool-result)渲染。
示例页面 包括示例应用程序,例如列出披萨餐厅的"披萨列表"应用程序。
探索 Pizzaz 组件画廊
Apps SDK 示例 包括示例组件。在设计您自己的 UI 时将它们视为蓝图:
- Pizzaz List: 带有收藏夹和行动号召按钮的排名卡片列表。

- Pizzaz Carousel: 基于 Embla 的水平滚动器,展示了媒体密集型布局。

- Pizzaz Map: 带有全屏检查器和主机状态同步的 Mapbox 集成。

- Pizzaz Album: 专为单个地点深度浏览而构建的堆叠画廊视图。

- Pizzaz Video: 带有覆盖层和全屏控制的脚本播放器。
每个示例都展示了如何捆绑资产、连接主机 API 以及为真实对话构建状态。复制最接近您用例的那个,并调整数据层以适应您的工具响应。
React 帮助器钩子
订阅 ui/notifications/tool-result 的小帮助器:
type ToolResult = { structuredContent?: unknown } | null;
export function useToolResult() {
const [toolResult, setToolResult] = useState<ToolResult>(null);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.method !== "ui/notifications/tool-result") return;
setToolResult(message.params ?? null);
};
window.addEventListener("message", onMessage, { passive: true });
return () => window.removeEventListener("message", onMessage);
}, []);
return toolResult;
}
从 toolResult?.structuredContent 渲染,并将其视为不受信任的输入。
小部件本地化
主机将区域设置镜像到 document.documentElement.lang。使用该区域设置加载翻译并格式化日期/数字。使用 react-intl 的常见模式:
import { IntlProvider } from "react-intl";
import en from "./locales/en-US.json";
import es from "./locales/es-ES.json";
const messages: Record<string, Record<string, string>> = {
"en-US": en,
"es-ES": es,
};
export function App() {
const locale = document.documentElement.lang || "en-US";
return (
<IntlProvider
locale={locale}
messages={messages[locale] ?? messages["en-US"]}
>
{/* Render UI with <FormattedMessage> or useIntl() */}
</IntlProvider>
);
}
为 iframe 打包
完成 React 组件的编写后,您可以将其构建为服务器可以内联的单个 JavaScript 模块:
// package.json
{
"scripts": {
"build": "esbuild src/component.tsx --bundle --format=esm --outfile=dist/component.js"
}
}
运行 npm run build 以生成 dist/component.js。如果 esbuild 抱怨缺少依赖项,请确认您在 web/ 目录中运行了 npm install,并且您的导入与已安装的包名称匹配(例如 @react-dnd/html5-backend 与 react-dnd-html5-backend)。
在服务器响应中嵌入组件
有关如何在 MCP 服务器响应中嵌入组件的信息,请参见 设置服务器文档。
组件 UI 模板是生产的推荐路径。
在开发期间,您可以在 React 代码更改时随时重新构建组件包,并热重载服务器。