ChatGPT 应用中的状态管理
本指南介绍了在使用 Apps SDK 和 MCP 服务器构建应用时,如何为 ChatGPT 内呈现的自定义 UI 组件管理状态。您将了解如何确定各类状态所属的位置,以及如何跨渲染周期和对话持久化这些状态。
这些模式可确保您的 UI 与宿主无关,从而实现 MCP 应用“一次构建,多处运行”的方法。
概述
ChatGPT 应用中的状态分为三类
| 状态类型 | 所属权 | 生命周期 | 示例 |
|---|---|---|---|
| 业务数据(权威数据) | MCP 服务器或后端服务 | 长效 | 任务、工单、文档 |
| UI 状态(临时) | ChatGPT 内的组件实例 | 仅限当前活跃组件 | 选中行、展开面板、排序方式 |
| 跨会话状态(持久) | 您的后端或存储系统 | 跨会话及跨对话 | 已保存的筛选器、视图模式、工作区选择 |
将每种状态放在正确的位置,以保持 UI 的一致性,并确保对话与预期意图相匹配。
UI 组件如何在 ChatGPT 中存活
当您的应用返回一个自定义 UI 组件时,ChatGPT 会在绑定到特定对话消息的窗口组件 (widget) 内渲染该组件。只要该消息存在于线程中,该窗口组件就会一直保持。
关键行为
- 窗口组件是消息作用域的: 每个返回窗口组件的响应都会创建一个带有自身 UI 状态的全新实例。
- UI 状态随组件留存: 当您重新打开或刷新同一消息时,组件会恢复其已保存的状态(如选中行、展开面板等)。
- 服务器数据是真理来源: 组件仅在工具调用完成后才会看到最新的业务数据,随后它会在此快照之上应用其本地 UI 状态。
心智模型
组件的 UI 层与数据层协同工作的方式如下
Server (MCP or backend)
│
├── Authoritative business data (source of truth)
│
▼
ChatGPT Widget
│
├── Ephemeral UI state (visual behavior)
│
└── Rendered view = authoritative data + UI state
这种分离确保了 UI 交互的流畅性,同时保证了数据的准确性。
1. 业务状态(权威数据)
业务数据是真理来源。它应该存储在您的 MCP 服务器或后端,而不是组件内部。
当用户执行操作时
- UI 调用服务器工具。
- 服务器更新数据。
- 服务器返回新的权威快照。
- 组件使用该快照重新渲染。
这防止了 UI 与服务器之间的数据不同步。
示例:从 MCP 服务器(Node.js)返回权威状态
import { Server } from "@modelcontextprotocol/sdk/server";
import { jsonSchema } from "@modelcontextprotocol/sdk/schema";
const tasks = new Map(); // replace with your DB or external service
let nextId = 1;
const taskListOutputSchema = {
type: "object",
properties: {
type: { type: "string", const: "taskList" },
tasks: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
done: { type: "boolean" },
},
required: ["id", "title", "done"],
additionalProperties: false,
},
},
},
required: ["type", "tasks"],
additionalProperties: false,
};
const server = new Server({
tools: {
get_tasks: {
description: "Return all tasks",
inputSchema: jsonSchema.object({}),
outputSchema: taskListOutputSchema,
async run() {
return {
structuredContent: {
type: "taskList",
tasks: Array.from(tasks.values()),
},
};
},
},
add_task: {
description: "Add a new task",
inputSchema: jsonSchema.object({ title: jsonSchema.string() }),
outputSchema: taskListOutputSchema,
async run({ title }) {
const id = `task-${nextId++}`; // simple example id
tasks.set(id, { id, title, done: false });
// Always return updated authoritative state
return this.tools.get_tasks.run({});
},
},
},
});
server.start();
2. UI 状态(临时数据)
UI 状态描述的是数据的呈现方式,而非数据本身。
当新的服务器数据到达时,组件不会自动重新同步 UI 状态。相反,组件会保留其 UI 状态,并在权威数据刷新时将其重新应用。
使用 UI 框架的状态(React state、signals 等)将 UI 状态存储在组件实例内部。对于新应用:
- 保持 UI 状态在 UI 内部局部化。
- 当模型需要了解 UI 状态(选定的筛选器、暂存的编辑内容)时,请调用
ui/update-model-context。
这能保持您的核心 UI 逻辑在兼容 MCP 应用的宿主环境间的可移植性。
ChatGPT 扩展(可选): 如果您希望 ChatGPT 为组件的生命周期持久化仅 UI 相关的状态,可以使用:
window.openai.widgetState– 读取当前的组件作用域状态快照。window.openai.setWidgetState(newState)– 写入下一个快照。该调用是同步的,持久化会在后台进行。
由于宿主是异步持久化组件状态的,调用 window.openai.setWidgetState 时无需 await。请将其视为更新本地组件状态,并在每次有意义的 UI 状态更改后立即调用它。
示例(React 组件)
此示例演示了 ChatGPT 窗口组件状态的持久化(可选)。如果想在 React 中使用它,请将 window.openai.widgetState 和 window.openai.setWidgetState 封装在一个小型 Hook(例如 useWidgetState)中,并在您的项目中导入。
import { useWidgetState } from "./use-widget-state";
export function TaskList({ data }) {
const [widgetState, setWidgetState] = useWidgetState(() => ({
selectedId: null,
}));
const selectTask = (id) => {
setWidgetState((prev) => ({ ...prev, selectedId: id }));
};
return (
<ul>
{data.tasks.map((task) => (
<li
key={task.id}
style={{
fontWeight: widgetState?.selectedId === task.id ? "bold" : "normal",
}}
onClick={() => selectTask(task.id)}
>
{task.title}
</li>
))}
</ul>
);
}
示例(原生 JS 组件)
let tasks = [];
let widgetState = window.openai?.widgetState ?? { selectedId: null };
const updateFromToolResult = (toolResult) => {
const nextTasks = toolResult?.structuredContent?.tasks;
if (!nextTasks) return;
tasks = nextTasks;
renderTasks();
};
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;
updateFromToolResult(message.params);
},
{ passive: true }
);
function selectTask(id) {
widgetState = { ...widgetState, selectedId: id };
window.openai?.setWidgetState?.(widgetState);
renderTasks();
}
function renderTasks() {
const list = document.querySelector("#task-list");
list.innerHTML = tasks
.map(
(task) => `
<li
style="font-weight: ${widgetState.selectedId === task.id ? "bold" : "normal"}"
onclick="selectTask('${task.id}')"
>
${task.title}
</li>
`
)
.join("");
}
renderTasks();
组件状态中的图像 ID(模型可见图像,ChatGPT 扩展)
如果您的组件处理图像,请使用结构化的组件状态格式,并包含一个 imageIds 数组。宿主会将这些文件 ID 在后续对话中暴露给模型,以便模型对这些图像进行推理。
推荐的格式为:
modelContent:模型应看到的文本或 JSON。privateContent:模型不应看到的仅 UI 相关的状态。imageIds:由组件上传的文件 ID 列表,通过window.openai.selectFiles()(当文件库可用时)选择,通过工具输入文件参数接收,或由工具文件引用返回。
type StructuredWidgetState = {
modelContent: string | Record<string, unknown> | null;
privateContent: Record<string, unknown> | null;
imageIds: string[];
};
const [state, setState] = useWidgetState<StructuredWidgetState>(null);
setState({
modelContent: "Check out the latest updated image",
privateContent: {
currentView: "image-viewer",
filters: ["crop", "sharpen"],
},
imageIds: ["file_123", "file_456"],
});
只有通过 window.openai.uploadFile 上传、通过 window.openai.selectFiles()(当可用时)选择、通过文件参数接收或从工具结果文件引用中收到的文件 ID 才能包含在 imageIds 中。
3. 跨会话状态
需要在不同对话、设备或会话间持久化的偏好设置,应存储在您的后端。
Apps SDK 会自动处理对话状态,但大多数实际应用还需要持久化存储。您可能需要缓存获取的数据、跟踪用户偏好或持久化组件内创建的工件。选择添加存储层会增加功能,但也增加了复杂性。
自带后端
如果您已有 API 或需要多人协作,请与现有的存储层集成。在这种模型下:
- 通过 OAuth 进行用户身份验证(请参阅身份验证),以便将 ChatGPT 身份映射到您的内部账户。
- 使用您的后端 API 获取和修改数据。保持低延迟;用户期望组件在几百毫秒内渲染完成。
- 返回结构化的内容,以便即便组件加载失败,模型也能理解数据。
当您自建存储时,请规划:
- 数据驻留与合规性 – 在传输 PII 或受监管数据之前,确保已达成相关协议。
- 速率限制 – 防止由于模型重试或多个活跃组件导致的 API 流量激增。
- 版本控制 – 在存储对象中包含架构版本,以便您在不破坏现有对话的情况下进行迁移。
示例:组件调用工具
此示例假设您有一个 JSON-RPC 请求/响应助手(例如来自 快速入门),可以发送 tools/call 请求。
import { useState } from "react";
export function PreferencesForm({ userId, initialPreferences }) {
const [formState, setFormState] = useState(initialPreferences);
const [isSaving, setIsSaving] = useState(false);
async function savePreferences(next) {
setIsSaving(true);
setFormState(next);
// Use the MCP Apps bridge (`tools/call`) to invoke tools from the UI.
// Ensure the tool is visible to the UI (app) in its descriptor (see
// `_meta.ui.visibility`).
const result = await rpcRequest("tools/call", {
name: "set_preferences",
arguments: { userId, preferences: next },
});
const updated = result?.structuredContent?.preferences ?? next;
setFormState(updated);
setIsSaving(false);
}
return (
<form>
{/* form fields bound to formState */}
<button
type="button"
disabled={isSaving}
onClick={() => savePreferences(formState)}
>
{isSaving ? "Saving…" : "Save preferences"}
</button>
</form>
);
}
示例:服务器处理工具调用(Node.js)
import { Server } from "@modelcontextprotocol/sdk/server";
import { jsonSchema } from "@modelcontextprotocol/sdk/schema";
import { request } from "undici";
// Helpers that call your existing backend API
async function readPreferences(userId) {
const response = await request(
`https://api.example.com/users/${userId}/preferences`,
{
method: "GET",
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
}
);
if (response.statusCode === 404) return {};
if (response.statusCode >= 400) throw new Error("Failed to load preferences");
return await response.body.json();
}
async function writePreferences(userId, preferences) {
const response = await request(
`https://api.example.com/users/${userId}/preferences`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(preferences),
}
);
if (response.statusCode >= 400) throw new Error("Failed to save preferences");
return await response.body.json();
}
const preferencesOutputSchema = {
type: "object",
properties: {
type: { type: "string", const: "preferences" },
preferences: { type: "object" },
},
required: ["type", "preferences"],
additionalProperties: false,
};
const server = new Server({
tools: {
get_preferences: {
inputSchema: jsonSchema.object({ userId: jsonSchema.string() }),
outputSchema: preferencesOutputSchema,
async run({ userId }) {
const preferences = await readPreferences(userId);
return { structuredContent: { type: "preferences", preferences } };
},
},
set_preferences: {
inputSchema: jsonSchema.object({
userId: jsonSchema.string(),
preferences: jsonSchema.object({}),
}),
outputSchema: preferencesOutputSchema,
async run({ userId, preferences }) {
const updated = await writePreferences(userId, preferences);
return {
structuredContent: { type: "preferences", preferences: updated },
};
},
},
},
});
摘要
- 在服务器上存储业务数据。
- 在组件内部存储 UI 状态(React state、signals 等)。当模型需要查看 UI 状态时使用
ui/update-model-context;仅当需要 ChatGPT 窗口组件状态持久化(可选)时使用window.openai.widgetState/window.openai.setWidgetState。 - 将跨会话状态存储在您管理的后端存储中。
- 组件状态仅针对特定消息所属的组件实例持久化。
- 避免使用
localStorage存储核心状态。