← Cloudflare Workers / workers / demos
Создайте интерактивное приложение ChatGPT
Разверните свое первое приложение ChatGPT App
В этом руководстве вы узнаете, как создать и развернуть интерактивное приложение ChatGPT App на Cloudflare Workers, которое умеет:
- Отображение насыщенных интерактивных UI-виджетов прямо в диалогах ChatGPT
- Поддержание состояния в реальном времени для нескольких пользователей с помощью Durable Objects
- Включите двустороннюю связь между вашим приложением и ChatGPT
- Создавайте многопользовательские сценарии, которые полностью работают внутри ChatGPT
Вы создадите многопользовательскую шахматную игру в реальном времени, которая демонстрирует эти возможности. Игроки смогут начинать партии или присоединяться к ним, делать ходы на интерактивной шахматной доске и даже спрашивать у ChatGPT совета по стратегии, не покидая диалог.
Ваше ChatGPT App будет использовать Model Context Protocol (MCP) чтобы предоставить инструменты и ресурсы интерфейса, которые ChatGPT сможет вызывать от вашего имени.
Полный код этого примера можно посмотреть здесь ↗.
Предварительные требования
Прежде чем начать, вам потребуется:
- A Аккаунт Cloudflare ↗
- Node.js ↗ установлен (v18 или новее)
- A Учетная запись ChatGPT Plus или Team ↗ с включенным режимом разработчика
- Базовые знания React и TypeScript
1. Включите ChatGPT Developer Mode
Чтобы использовать ChatGPT Apps (также называемые коннекторами), включите режим разработчика:
- Откройте ChatGPT ↗.
- Перейдите в Настройки > Apps & Connectors > Дополнительные настройки
- Переключить Режим разработчика включён
После включения этой функции можно будет устанавливать пользовательские приложения во время разработки и тестирования.
2. Создание проекта ChatGPT App
- Создайте новый проект для своего приложения Chess App:
npm create cloudflare@latest -- my-chess-app- Перейдите в каталог проекта:
cd my-chess-app- Установите необходимые зависимости:
npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard- Установите зависимости для разработки:
npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom3. Настройка вашего проекта
- Обновите ваш
wrangler.jsoncчтобы настроить Durable Objects и ресурсы:
{
"name": "my-chess-app",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-28",
"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-28"
compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]
name = "CHESS"
class_name = "ChessGame"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ChessGame" ]
[assets]
directory = "dist"
binding = "ASSETS"- Создайте
vite.config.tsдля создания интерфейса React:
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,
},
});- Обновите ваш
package.jsonscripts:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy": "vite build && wrangler deploy"
}
}4. Создайте игровой движок для шахмат
- Создайте игровую логику с помощью Durable Objects по адресу
src/chess.tsx:
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-сервер и ресурс интерфейса
- Создайте основной worker по адресу
src/index.ts:
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
- Создайте точку входа HTML по адресу
index.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>- Создайте приложение React по адресу
src/app.tsx:
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. Соберите и разверните
- Создайте пользовательский интерфейс React:
npm run buildЭта команда компилирует приложение React в единый HTML-файл в dist каталог.
- Deploy to Cloudflare:
npx wrangler deployПосле развёртывания вы увидите URL своего приложения:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev8. Подключитесь к ChatGPT
Теперь подключите развернутое приложение к ChatGPT:
- Откройте ChatGPT ↗.
- Перейдите в Настройки > Apps & Connectors > Создание
- Дайте своему приложению имя, и, при необходимости, описание и значок.
- Введите ваш MCP endpoint:
https://my-chess-app.YOUR_SUBDOMAIN.workers.dev/mcp. - Выберите «Без аутентификации».
- Выберите «Создать».
9. Играйте в шахматы в ChatGPT
Попробовать:
- В диалоге ChatGPT введите: «Let's play chess».
- ChatGPT вызовет
playChessинструмент и отобразить ваш интерактивный шахматный виджет. - Выберите «Начать новую игру» чтобы создать игру.
- Поделитесь ID игры с другом, который сможет присоединиться через свой разговор в ChatGPT.
- Делайте ходы, перетаскивая фигуры на доске.
- Выберите «Обратиться за помощью» чтобы получить стратегические рекомендации от ChatGPT
Основные понятия
MCP Server
Сервер 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;
}Игровой движок с агентами
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 хук подключает ваше 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);
},
});Вызывайте методы агента:
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 tools и resources.
- Улучшите интерфейс: создавайте более сложные интерфейсы с React.