跳转到内容
搜索文档

构建交互式 ChatGPT 应用

最后更新 查看 MarkdownAgent 设置

部署你的第一个 ChatGPT 应用

本指南将向你展示如何在 Cloudflare Workers 上构建并部署交互式 ChatGPT 应用,该应用可以:

  • 在 ChatGPT 对话中直接渲染丰富的交互式 UI 组件
  • 使用 Durable Objects 维护实时、多用户状态
  • 实现应用与 ChatGPT 之间的双向通信
  • 构建完全在 ChatGPT 内运行的多人体验

你将构建一个实时多人国际象棋游戏来演示这些能力。玩家可以开始或加入游戏、在交互式棋盘上走棋,甚至向 ChatGPT 寻求战略建议——全程无需离开对话。

你的 ChatGPT 应用将使用 Model Context Protocol (MCP) 公开 ChatGPT 可以代表你调用的工具和 UI 资源。

你可以在此处查看此示例的完整代码。

前置条件

开始之前,你需要:

1. 启用 ChatGPT 开发者模式

要使用 ChatGPT 应用(也称为连接器),你需要启用开发者模式:

  1. 打开 ChatGPT
  2. 前往 Settings(设置) > Apps & Connectors(应用和连接器) > Advanced Settings(高级设置)
  3. Developer mode 切换为 ON

启用后,你将在开发和测试期间能够安装自定义应用。

2. 创建 ChatGPT 应用项目

  1. 为你的国际象棋应用创建新项目:
npm create cloudflare@latest -- my-chess-app
  1. 进入项目目录:
cd my-chess-app
  1. 安装所需依赖:
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard
  1. 安装开发依赖:
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom

3. 配置项目

  1. 更新 wrangler.jsonc,配置 Durable Objects 和静态资源:
{
	"name": "my-chess-app",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-08-17",
	"compatibility_flags": ["nodejs_compat"],
	"durable_objects": {
		"bindings": [
			{
				"name": "CHESS",
				"class_name": "ChessGame",
			},
		],
	},
	"migrations": [
		{
			"tag": "v1",
			"new_sqlite_classes": ["ChessGame"],
		},
	],
	"assets": {
		"directory": "dist",
		"binding": "ASSETS",
	},
}
name = "my-chess-app"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
compatibility_flags = [ "nodejs_compat" ]

[[durable_objects.bindings]]
name = "CHESS"
class_name = "ChessGame"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChessGame" ]

[assets]
directory = "dist"
binding = "ASSETS"
  1. 创建 vite.config.ts 用于构建 React UI:
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,
	},
});
  1. 更新 package.json 中的脚本:
{
	"scripts": {
		"dev": "vite",
		"build": "vite build",
		"deploy": "vite build && wrangler deploy"
	}
}

4. 创建国际象棋游戏引擎

  1. 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 };
	}
}

5. 创建 MCP 服务器和 UI 资源

  1. src/index.ts 创建主 Worker:
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";

6. 构建 React UI

  1. 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>
  1. src/app.tsx 创建 React 应用:
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 />);

7. 构建并部署

  1. 构建 React UI:
npm run build

这会将 React 应用编译为 dist 目录中的单个 HTML 文件。

  1. 部署到 Cloudflare:
npx wrangler deploy

部署完成后,你将看到应用 URL:

https://my-chess-app.YOUR_SUBDOMAIN.workers.dev

8. 连接到 ChatGPT

现在将已部署的应用连接到 ChatGPT:

  1. 打开 ChatGPT
  2. 前往 Settings(设置) > Apps & Connectors(应用和连接器) > Create(创建)
  3. 为应用填写 name,并可选择填写 descriptionicon
  4. 输入 MCP 端点:https://my-chess-app.YOUR_SUBDOMAIN.workers.dev/mcp
  5. 选择 "No authentication"
  6. 选择 "Create"

9. 在 ChatGPT 中下棋

试试看:

  1. 在 ChatGPT 对话中输入:"Let's play chess"。
  2. ChatGPT 将调用 playChess 工具并渲染你的交互式国际象棋组件。
  3. 选择 "Start a new game" 创建游戏。
  4. 将游戏 ID 分享给朋友,他们可以通过自己的 ChatGPT 对话加入。
  5. 在棋盘上拖动棋子来走棋。
  6. 选择 "Ask for help" 向 ChatGPT 获取战略建议

关键概念

MCP 服务器

Model Context Protocol (MCP) 服务器定义 ChatGPT 可以访问的工具和资源。请注意,我们为每个请求创建一个新的服务器实例,以防止跨客户端响应泄漏:

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;
}

使用 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);
  }

每局游戏都会获得自己的 Agent 实例,从而实现:

  • 每局游戏独立状态
  • 跨玩家实时同步
  • 在 Worker 重启后仍然保留的持久化存储

可调用方法

使用 @callable() 装饰器公开客户端可以调用的方法:

@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 };
}

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);
	},
});

在 Agent 上调用方法:

const res = await stub.join({ playerId, preferred: "any" });
await stub.move({ from: "e2", to: "e4" });

双向通信

你的应用可以向 ChatGPT 发送消息:

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 应用,你可以:

  • 添加更多工具:通过 MCP 工具和资源公开更多功能和 UI。
  • 增强 UI:使用 React 构建更复杂的界面。

相关资源

这篇文档对您有帮助吗?