构建一个交互式 ChatGPT App
最近一次审阅:6 个月前
部署你的第一个 ChatGPT App
本指南将带你在 Cloudflare Workers 上构建并部署一个交互式 ChatGPT App,它能够:
- 在 ChatGPT 对话中直接渲染富交互的 UI 小组件
- 使用 Durable Objects 维护实时的多用户状态
- 在你的应用与 ChatGPT 之间实现双向通信
- 构建完全运行在 ChatGPT 内的多人游戏体验
你将构建一个实时多人国际象棋游戏来演示这些能力。玩家可以开始或加入游戏、在交互式棋盘上落子,甚至向 ChatGPT 寻求战术建议 —— 整个过程都不必离开对话。
你的 ChatGPT App 将使用 Model Context Protocol (MCP) 来暴露 ChatGPT 可以替你调用的工具和 UI 资源。
可以在 这里 ↗ 查看本示例的完整代码。
前提条件
开始之前,你需要:
- 一个 Cloudflare 账户 ↗
- 已安装 Node.js ↗(v18 或更高版本)
- 一个开启了开发者模式的 ChatGPT Plus 或 Team 账户 ↗
- 基础的 React 与 TypeScript 知识
1. 启用 ChatGPT 开发者模式
要使用 ChatGPT Apps(也叫 connector),你需要启用开发者模式:
- 打开 ChatGPT ↗。
- 进入 Settings > Apps & Connectors > Advanced Settings。
- 把 Developer mode 切换为 ON。
启用之后,你就可以在开发和测试期间安装自定义应用了。
2. 创建 ChatGPT App 项目
- 为国际象棋 App 创建一个新项目:
npm yarn pnpm
npm create cloudflare@latest -- my-chess-app
yarn create cloudflare my-chess-app
pnpm create cloudflare@latest my-chess-app
- 进入项目目录:
Terminal window
cd my-chess-app
- 安装所需依赖:
Terminal window
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard
- 安装开发依赖:
Terminal window
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom
3. 配置项目
- 更新
wrangler.jsonc来配置 Durable Objects 和静态资源:
JSONC
{
"name": "my-chess-app",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-04-29",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [
{
"name": "CHESS",
"class_name": "ChessGame",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["ChessGame"],
},
],
"assets": {
"directory": "dist",
"binding": "ASSETS",
},
}
Explain Code
TOML
name = "my-chess-app"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-04-29"
compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]
name = "CHESS"
class_name = "ChessGame"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChessGame" ]
[assets]
directory = "dist"
binding = "ASSETS"
Explain Code
- 创建
vite.config.ts,用来构建 React UI:
TypeScript
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), cloudflare(), viteSingleFile()],
build: {
minify: false,
},
});
Explain Code
- 更新
package.json中的脚本:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy": "vite build && wrangler deploy"
}
}
4. 创建国际象棋游戏引擎
- 在
src/chess.tsx中,使用 Durable Objects 实现游戏逻辑:
import { Agent, callable, getCurrentAgent } from "agents";
import { Chess } from "chess.js";
type Color = "w" | "b";
type ConnectionState = {
playerId: string;
};
export type State = {
board: string;
players: { w?: string; b?: string };
status: "waiting" | "active" | "mate" | "draw" | "resigned";
winner?: Color;
lastSan?: string;
};
export class ChessGame extends Agent<Env, State> {
initialState: State = {
board: new Chess().fen(),
players: {},
status: "waiting",
};
game = new Chess();
constructor(
ctx: DurableObjectState,
public env: Env,
) {
super(ctx, env);
this.game.load(this.state.board);
}
private colorOf(playerId: string): Color | undefined {
const { players } = this.state;
if (players.w === playerId) return "w";
if (players.b === playerId) return "b";
return undefined;
}
@callable()
join(params: { playerId: string; preferred?: Color | "any" }) {
const { playerId, preferred = "any" } = params;
const { connection } = getCurrentAgent();
if (!connection) throw new Error("Not connected");
connection.setState({ playerId });
const s = this.state;
// Already seated? Return seat
const already = this.colorOf(playerId);
if (already) {
return { ok: true, role: already as Color, state: s };
}
// Choose a seat
const free: Color[] = (["w", "b"] as const).filter((c) => !s.players[c]);
if (free.length === 0) {
return { ok: true, role: "spectator" as const, state: s };
}
let seat: Color = free[0];
if (preferred === "w" && free.includes("w")) seat = "w";
if (preferred === "b" && free.includes("b")) seat = "b";
s.players[seat] = playerId;
s.status = s.players.w && s.players.b ? "active" : "waiting";
this.setState(s);
return { ok: true, role: seat, state: s };
}
@callable()
move(
move: { from: string; to: string; promotion?: string },
expectedFen?: string,
) {
if (this.state.status === "waiting") {
return {
ok: false,
reason: "not-in-game",
fen: this.game.fen(),
status: this.state.status,
};
}
const { connection } = getCurrentAgent();
if (!connection) throw new Error("Not connected");
const { playerId } = connection.state as ConnectionState;
const seat = this.colorOf(playerId);
if (!seat) {
return {
ok: false,
reason: "not-in-game",
fen: this.game.fen(),
status: this.state.status,
};
}
if (seat !== this.game.turn()) {
return {
ok: false,
reason: "not-your-turn",
fen: this.game.fen(),
status: this.state.status,
};
}
// Optimistic sync guard
if (expectedFen && expectedFen !== this.game.fen()) {
return {
ok: false,
reason: "stale",
fen: this.game.fen(),
status: this.state.status,
};
}
const res = this.game.move(move);
if (!res) {
return {
ok: false,
reason: "illegal",
fen: this.game.fen(),
status: this.state.status,
};
}
const fen = this.game.fen();
let status: State["status"] = "active";
if (this.game.isCheckmate()) status = "mate";
else if (this.game.isDraw()) status = "draw";
this.setState({
...this.state,
board: fen,
lastSan: res.san,
status,
winner:
status === "mate" ? (this.game.turn() === "w" ? "b" : "w") : undefined,
});
return { ok: true, fen, san: res.san, status };
}
@callable()
resign() {
const { connection } = getCurrentAgent();
if (!connection) throw new Error("Not connected");
const { playerId } = connection.state as ConnectionState;
const seat = this.colorOf(playerId);
if (!seat) return { ok: false, reason: "not-in-game", state: this.state };
const winner = seat === "w" ? "b" : "w";
this.setState({ ...this.state, status: "resigned", winner });
return { ok: true, state: this.state };
}
}
Explain Code
5. 创建 MCP 服务器与 UI 资源
- 在
src/index.ts中创建主 worker:
TypeScript
import { createMcpHandler } from "agents/mcp";
import { routeAgentRequest } from "agents";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { env } from "cloudflare:workers";
const getWidgetHtml = async (host: string) => {
let html = await (await env.ASSETS.fetch("http://localhost/")).text();
html = html.replace(
"<!--RUNTIME_CONFIG-->",
`<script>window.HOST = \`${host}\`;</script>`,
);
return html;
};
function createServer() {
const server = new McpServer({ name: "Chess", version: "v1.0.0" });
// Register a UI resource that ChatGPT can render
server.registerResource(
"chess",
"ui://widget/index.html",
{},
async (_uri, extra) => {
return {
contents: [
{
uri: "ui://widget/index.html",
mimeType: "text/html+skybridge",
text: await getWidgetHtml(
extra.requestInfo?.headers.host as string,
),
},
],
};
},
);
// Register a tool that ChatGPT can call to render the UI
server.registerTool(
"playChess",
{
title: "Renders a chess game menu, ready to start or join a game.",
annotations: { readOnlyHint: true },
_meta: {
"openai/outputTemplate": "ui://widget/index.html",
"openai/toolInvocation/invoking": "Opening chess widget",
"openai/toolInvocation/invoked": "Chess widget opened",
},
},
async (_, _extra) => {
return {
content: [
{ type: "text", text: "Successfully rendered chess game menu" },
],
};
},
);
return server;
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(req.url);
if (url.pathname.startsWith("/mcp")) {
// Create a new server instance per request
const server = createServer();
return createMcpHandler(server)(req, env, ctx);
}
return (
(await routeAgentRequest(req, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;
export { ChessGame } from "./chess";
Explain Code
6. 构建 React UI
- 在
index.html创建 HTML 入口:
<!doctype html>
<html>
<head>
<!--RUNTIME_CONFIG-->
</head>
<body>
<div id="root" style="font-family: verdana"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>
Explain Code
- 在
src/app.tsx创建 React app:
import { useEffect, useRef, useState } from "react";
import { useAgent } from "agents/react";
import { createRoot } from "react-dom/client";
import { Chess, type Square } from "chess.js";
import { Chessboard, type PieceDropHandlerArgs } from "react-chessboard";
import type { State as ServerState } from "./chess";
function usePlayerId() {
const [pid] = useState(() => {
const existing = localStorage.getItem("playerId");
if (existing) return existing;
const id = crypto.randomUUID();
localStorage.setItem("playerId", id);
return id;
});
return pid;
}
function App() {
const playerId = usePlayerId();
const [gameId, setGameId] = useState<string | null>(null);
const [gameIdInput, setGameIdInput] = useState("");
const [menuError, setMenuError] = useState<string | null>(null);
const gameRef = useRef(new Chess());
const [fen, setFen] = useState(gameRef.current.fen());
const [myColor, setMyColor] = useState<"w" | "b" | "spectator">("spectator");
const [pending, setPending] = useState(false);
const [serverState, setServerState] = useState<ServerState | null>(null);
const [joined, setJoined] = useState(false);
const host = window.HOST ?? "http://localhost:5173/";
const { stub } = useAgent<ServerState>({
host,
name: gameId ?? "__lobby__",
agent: "chess",
onStateUpdate: (s) => {
if (!gameId) return;
gameRef.current.load(s.board);
setFen(s.board);
setServerState(s);
},
});
useEffect(() => {
if (!gameId || joined) return;
(async () => {
try {
const res = await stub.join({ playerId, preferred: "any" });
if (!res?.ok) return;
setMyColor(res.role);
gameRef.current.load(res.state.board);
setFen(res.state.board);
setServerState(res.state);
setJoined(true);
} catch (error) {
console.error("Failed to join game", error);
}
})();
}, [playerId, gameId, stub, joined]);
async function handleStartNewGame() {
const newId = crypto.randomUUID();
setGameId(newId);
setGameIdInput(newId);
setMenuError(null);
setJoined(false);
}
async function handleJoinGame() {
const trimmed = gameIdInput.trim();
if (!trimmed) {
setMenuError("Enter a game ID to join.");
return;
}
setGameId(trimmed);
setMenuError(null);
setJoined(false);
}
const handleHelpClick = () => {
window.openai?.sendFollowUpMessage?.({
prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice.`,
});
};
function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) {
if (!gameId || !sourceSquare || !targetSquare || pending) return false;
const game = gameRef.current;
if (myColor === "spectator" || game.turn() !== myColor) return false;
const piece = game.get(sourceSquare as Square);
if (!piece || piece.color !== myColor) return false;
const prevFen = game.fen();
try {
const local = game.move({
from: sourceSquare,
to: targetSquare,
promotion: "q",
});
if (!local) return false;
} catch {
return false;
}
const nextFen = game.fen();
setFen(nextFen);
setPending(true);
stub
.move({ from: sourceSquare, to: targetSquare, promotion: "q" }, prevFen)
.then((r) => {
if (!r.ok) {
game.load(r.fen);
setFen(r.fen);
}
})
.finally(() => setPending(false));
return true;
}
return (
<div style={{ padding: "20px", background: "#f8fafc", minHeight: "100vh" }}>
{!gameId ? (
<div
style={{
maxWidth: "420px",
margin: "0 auto",
background: "#fff",
borderRadius: "16px",
padding: "24px",
}}
>
<h1>Ready to play?</h1>
<p>Start a new match or join an existing game.</p>
<button
onClick={handleStartNewGame}
style={{
padding: "12px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: "8px",
cursor: "pointer",
width: "100%",
}}
>
Start a new game
</button>
<div style={{ marginTop: "16px" }}>
<input
placeholder="Paste a game ID"
value={gameIdInput}
onChange={(e) => setGameIdInput(e.target.value)}
style={{
width: "100%",
padding: "10px",
borderRadius: "8px",
border: "1px solid #ccc",
}}
/>
<button
onClick={handleJoinGame}
style={{
marginTop: "8px",
padding: "10px",
background: "#0f172a",
color: "#fff",
border: "none",
borderRadius: "8px",
cursor: "pointer",
width: "100%",
}}
>
Join
</button>
{menuError && (
<p style={{ color: "red", fontSize: "0.85rem" }}>{menuError}</p>
)}
</div>
</div>
) : (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<div
style={{
background: "#fff",
padding: "16px",
borderRadius: "16px",
marginBottom: "16px",
}}
>
<h2>Game {gameId}</h2>
<p>Status: {serverState?.status}</p>
<button
onClick={handleHelpClick}
style={{
padding: "10px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: "8px",
cursor: "pointer",
}}
>
Ask for help
</button>
</div>
<div
style={{
background: "#fff",
padding: "16px",
borderRadius: "16px",
}}
>
<Chessboard
position={fen}
onPieceDrop={onPieceDrop}
boardOrientation={myColor === "b" ? "black" : "white"}
/>
</div>
</div>
)}
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
Explain Code
注意
这是 UI 的简化版本。如果想看完整实现 —— 包括玩家位、更精致的样式以及游戏状态管理 —— 请查看 GitHub 上的完整示例 ↗。
7. 构建并部署
- 构建 React UI:
Terminal window
npm run build
这会把你的 React 应用编译成 dist 目录里的一个单文件 HTML。
- 部署到 Cloudflare:
Terminal window
npx wrangler deploy
部署完成后,你会看到自己应用的 URL:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev
8. 连接到 ChatGPT
现在把你部署好的应用连接到 ChatGPT:
- 打开 ChatGPT ↗。
- 进入 Settings > Apps & Connectors > Create。
- 给你的应用取个 name,可以选填 description 和 icon。
- 输入 MCP 端点:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev/mcp。 - 选择 “No authentication”。
- 选择 “Create”。
9. 在 ChatGPT 里下棋
试一试:
- 在你的 ChatGPT 对话里输入:“Let’s play chess”。
- ChatGPT 会调用
playChess工具,渲染你的交互式国际象棋小组件。 - 选择 “Start a new game” 创建一个游戏。
- 把游戏 ID 分享给一位朋友,他可以从自己的 ChatGPT 对话中加入。
- 通过在棋盘上拖动棋子来落子。
- 选择 “Ask for help” 让 ChatGPT 给出战术建议。
注意
第一次使用时,你可能需要在输入框里手动选中这个 connector。选择 “+” > “More” > [App name]。
关键概念
MCP Server
Model Context Protocol (MCP) 服务器定义了 ChatGPT 可访问的工具和资源。注意我们为每个请求都新建了一个 server 实例,以避免不同客户端之间的响应串扰:
TypeScript
function createServer() {
const server = new McpServer({ name: "Chess", version: "v1.0.0" });
// Register a UI resource that ChatGPT can render
server.registerResource(
"chess",
"ui://widget/index.html",
{},
async (_uri, extra) => {
return {
contents: [
{
uri: "ui://widget/index.html",
mimeType: "text/html+skybridge",
text: await getWidgetHtml(
extra.requestInfo?.headers.host as string,
),
},
],
};
},
);
// Register a tool that ChatGPT can call to render the UI
server.registerTool(
"playChess",
{
title: "Renders a chess game menu, ready to start or join a game.",
annotations: { readOnlyHint: true },
_meta: {
"openai/outputTemplate": "ui://widget/index.html",
"openai/toolInvocation/invoking": "Opening chess widget",
"openai/toolInvocation/invoked": "Chess widget opened",
},
},
async (_, _extra) => {
return {
content: [
{ type: "text", text: "Successfully rendered chess game menu" },
],
};
},
);
return server;
}
Explain Code
用 Agents 实现游戏引擎
ChessGame 类继承 Agent,构成一个有状态的游戏引擎:
export class ChessGame extends Agent<Env, State> {
initialState: State = {
board: new Chess().fen(),
players: {},
status: "waiting"
};
game = new Chess();
constructor(
ctx: DurableObjectState,
public env: Env
) {
super(ctx, env);
this.game.load(this.state.board);
}
Explain Code
每场游戏都拥有自己的 Agent 实例,从而获得:
- 每场游戏 隔离的状态
- 玩家之间的 实时同步
- 即使 worker 重启也不会丢失的 持久化存储
Callable 方法
使用 @callable() 装饰器,把方法暴露给客户端调用:
TypeScript
@callable()
join(params: { playerId: string; preferred?: Color | "any" }) {
const { playerId, preferred = "any" } = params;
const { connection } = getCurrentAgent();
if (!connection) throw new Error("Not connected");
connection.setState({ playerId });
const s = this.state;
// Already seated? Return seat
const already = this.colorOf(playerId);
if (already) {
return { ok: true, role: already as Color, state: s };
}
// Choose a seat
const free: Color[] = (["w", "b"] as const).filter((c) => !s.players[c]);
if (free.length === 0) {
return { ok: true, role: "spectator" as const, state: s };
}
let seat: Color = free[0];
if (preferred === "w" && free.includes("w")) seat = "w";
if (preferred === "b" && free.includes("b")) seat = "b";
s.players[seat] = playerId;
s.status = s.players.w && s.players.b ? "active" : "waiting";
this.setState(s);
return { ok: true, role: seat, state: s };
}
Explain Code
React 集成
useAgent hook 把你的 React 应用连接到 Durable Object:
const { stub } = useAgent<ServerState>({
host,
name: gameId ?? "__lobby__",
agent: "chess",
onStateUpdate: (s) => {
gameRef.current.load(s.board);
setFen(s.board);
setServerState(s);
},
});
Explain Code
调用 agent 上的方法:
const res = await stub.join({ playerId, preferred: "any" });
await stub.move({ from: "e2", to: "e4" });
双向通信
你的应用可以向 ChatGPT 发送消息:
TypeScript
const handleHelpClick = () => {
window.openai?.sendFollowUpMessage?.({
prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice as there are no tools for you to use.`,
});
};
这会在 ChatGPT 对话中创建一条新消息,带上当前游戏状态作为上下文。
下一步
现在你有了一个能用的 ChatGPT App,你可以:
- 添加更多工具:通过 MCP 工具和资源暴露更多能力和 UI。
- 增强 UI:使用 React 构建更精致的界面。
相关资源
Agents API Agents SDK 的完整 API 参考。
Durable Objects 了解底层的有状态基础设施。
Model Context Protocol MCP 规范与文档。
OpenAI Apps SDK OpenAI Apps SDK 官方参考。