Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

构建一个交互式 ChatGPT App

最近一次审阅:6 个月前

部署你的第一个 ChatGPT App

本指南将带你在 Cloudflare Workers 上构建并部署一个交互式 ChatGPT App,它能够:

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

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

你的 ChatGPT App 将使用 Model Context Protocol (MCP) 来暴露 ChatGPT 可以替你调用的工具和 UI 资源。

可以在 这里 ↗ 查看本示例的完整代码。

前提条件

开始之前,你需要:

1. 启用 ChatGPT 开发者模式

要使用 ChatGPT Apps(也叫 connector),你需要启用开发者模式:

  1. 打开 ChatGPT ↗
  2. 进入 Settings > Apps & Connectors > Advanced Settings
  3. Developer mode 切换为 ON

启用之后,你就可以在开发和测试期间安装自定义应用了。

2. 创建 ChatGPT App 项目

  1. 为国际象棋 App 创建一个新项目:

npm yarn pnpm

npm create cloudflare@latest -- my-chess-app
yarn create cloudflare my-chess-app
pnpm create cloudflare@latest my-chess-app
  1. 进入项目目录:

Terminal window


cd my-chess-app


  1. 安装所需依赖:

Terminal window


npm install agents @modelcontextprotocol/sdk chess.js react react-dom react-chessboard


  1. 安装开发依赖:

Terminal window


npm install -D @cloudflare/vite-plugin @vitejs/plugin-react vite vite-plugin-singlefile @types/react @types/react-dom


3. 配置项目

  1. 更新 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

  1. 创建 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

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

  }

}


Explain Code

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

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

  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>


Explain Code

  1. 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. 构建并部署

  1. 构建 React UI:

Terminal window


npm run build


这会把你的 React 应用编译成 dist 目录里的一个单文件 HTML。

  1. 部署到 Cloudflare:

Terminal window


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 给出战术建议。

注意

第一次使用时,你可能需要在输入框里手动选中这个 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 官方参考。