免费POC, 零成本试错
AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


全网首发 OpenAI Apps SDK 使用教程

发布日期:2025-10-13 18:51:48 浏览次数: 1522
作者:赋范大模型技术圈

微信搜一搜,关注“赋范大模型技术圈”

推荐语

OpenAI Apps SDK 重磅发布,带你玩转ChatGPT内应用开发,实现商业闭环!

核心内容:
1. Apps SDK的核心功能与商业价值
2. 官方示例项目实战演示(Python版)
3. 从零构建自定义应用的全流程指南

杨芳贤
53AI创始人/腾讯云(TVP)最具价值专家
OpenAI Apps SDK 教程(附pizza点单案例)
Apps SDK 是 OpenAI 于 2025 年 10 月 10 日 在 DevDay 开发者大会 上正式发布的一套全新开发框架。
它为开发者提供了在 ChatGPT 平台内构建与运行 Apps 的标准化途径,使得第三方应用可以嵌入 ChatGPT 的对话界面,拥有独立的前端交互体验与可视化组件。
这或许意味着 ChatGPT 内 应用生态商业化 的开端,用户不仅能在对话中使用第三方 App,还能直接通过这些 App 完成消费与支付,实现资金与流量的闭环。
一、亮点: Apps inside ChatGPT
第三方应用可直接集成到ChatGPT对话界面中
用户可在ChatGPT内直接与应用可视化交互,无需跳转
提供基于MCP标准构建的Apps SDK供开发者使用​
Apps SDK 简介
Apps SDK 基于 MCP 标准,扩展了 MCP 以使开发者能够设计应用逻辑和界面。APP 存在于 ChatGPT 内部,用于扩展用户的功能,同时又不会打断对话流程,并通过轻量级卡片、轮播、全屏视图和其他显示模式无缝集成到 ChatGPT 界面,同时保持其清晰度、可信度和语音功能。
APP 作为 MCP 扩展节点,每个第三方 App 的后端可以看作是一个 MCP 服务器,负责对外暴露能力。前端 UI 嵌入 ChatGPT 对话内, 并可以与 MCP 服务器双向通信。

扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。


二、Apps SDK 教程
可使用node.js或python作为后端
下面将通过两部分来演示Apps SDK的使用:
  • 官方示例项目(python)
  • 自建两个简单的示例工具
官方示例项目演示
克隆项目到本地,然后安装依赖并本地运行前端项目。
若没有安装pnpm,先安装pnpm
npm install -g pnpm@latest-10
# 克隆项目到本地
git clone https://github.com/openai/openai-apps-sdk-examples.git

# 安装依赖并本地运行前端项目
pnpm install

pnpm run build

pnpm run dev
创建并激活 Python 虚拟环境,安装pizzaz_server的依赖并启动服务器。
# 创建虚拟环境
python -m venv .venv
# 激活虚拟环境
.venv\Scripts\Activate
# 安装pizzaz_server的依赖
pip install -r pizzaz_server_python/requirements.txt
# 启动服务器 这里的8000端口作为后面ngrok暴露的端口
uvicorn pizzaz_server_python.main:app --port 8000
安装ngrok(若有ngrok,跳到下一步)
在Microsoft Store中下载ngrok

到 官网 https://dashboard.ngrok.com/ 注册ngrok账号,获取token,运行官网的配置token命令
ngrok config add-authtoken <token>
通过ngrok将本地服务器暴露到公网,获取临时URL
ngrok http 8000
启用开发者模式
在ChatGPT中 设置->应用与连接器->高级设置->启用开发者模式
创建连接器
将ngrok提供的临时URL拼接上/mcp,在ChatGPT应用与连接器中创建一个新的连接器,名称填写pizza,MCP 服务器 URL填写拼接后的URL。
测试连接器
现在,我们可以在ChatGPT中提问,例如:pizza 奶酪披萨地图。模型会返回可视化pizza地图,用户可以直接在ChatGPT中查看。

扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。

新建自定义示例
接下来我们新建两个简单的示例来演示Apps SDK的使用。
新建 whoami 工具
/src下新建文件夹whoami,并新建文件index.jsx
// 示例:whoami 工具 最基础的工具示例
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
//这里的App组件将作为chatgpt内的whoami工具的前端界面
function App({
  const [surprise, setSurprise] = useState(false);
  return (
    <div className="w-full h-full flex items-center justify-center p-6">
      <div className="rounded-2xl border border-black/10 dark:border-white/10 p-8 shadow bg-white text-black text-center">
        <h1 className="text-2xl font-semibold">我是 pizza 助手</h1>
        <p className="mt-2 text-sm text-black/70">在这里为你提供披萨相关帮助 🍕</p>
        <button
          className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white hover:bg-black/80 active:scale-[0.99] transition"
          onClick={() =>
 setSurprise(true)}
        >
          点击我有惊喜
        </button>
        {surprise && (
          <div className="mt-4 text-5xl select-none" aria-label="烟花">
            <span className="inline-block animate-bounce">🎆</span>
          </div>
        )}
      </div>
    </div>

  );
}
// 将组件绑定到 pizzaz-whoami-root 节点 
createRoot(document.getElementById("pizzaz-whoami-root")).render(<App />);
新建 order 工具
/src下新建文件夹pizzaz-order,并新建文件index.jsx包含点单页面和模拟付款页面。通过监听 toolOutput 与 toolInput 获取 ChatGPT 给的点单信息并反映到购物车。
// 示例:order 工具 含MCP交互的工具示例
import { useMemo, useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Routes, Route, useNavigate, useLocation } from "react-router-dom";
import "../index.css";
import { useOpenAiGlobal } from "../use-openai-global";

// 示例披萨数据(包含id、名称、图片、价格)
const PIZZAS = [
  {
    id: 1,
    name: "玛格丽塔披萨",
    price: 10,
    image:
      "https://tse1.mm.bing.net/th/id/OIP.g4QYOOmFvL-Kxpk4AuI3-gHaE7?cb=12&rs=1&pid=ImgDetMain&o=7&rm=3",
  },
  {
    id: 2,
    name: "夏威夷披萨",
    price: 15,
    image:
      "https://tse4.mm.bing.net/th/id/OIP.veSCe42vltnOTEhL8sPAsQHaLP?cb=12&rs=1&pid=ImgDetMain&o=7&rm=3",
  },
  {
    id: 3,
    name: "培根蘑菇披萨",
    price: 22,
    image:
      "https://tse3.mm.bing.net/th/id/OIP.8nCs6Gpm5ckETI-aRrePIwHaE8?cb=12&rs=1&pid=ImgDetMain&o=7&rm=3",
  },
];

function formatCurrency(n) {
  return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(n);
}

function OrderPage() {
  const navigate = useNavigate();
  const [cart, setCart] = useState({});
  const seededRef = useRef(false);
  // 监听 toolOutput 与 toolInput
  const toolOutput = useOpenAiGlobal("toolOutput");
  const toolInput = useOpenAiGlobal("toolInput");
  const mergedProps = useMemo(() => ({ ...(toolInput ?? {}), ...(toolOutput ?? {}) }), [toolInput, toolOutput]);
  const { orderItems = [] } = mergedProps;
  // 查找披萨
  const findPizzaByInput = (item) => {
    const name = item?.name;
    if (!name) return null;
    return PIZZAS.find((p) => p.name === name);
  };

  // 将初始条目注入购物车(chatgpt给的点单信息)
  useEffect(() => {
    if (seededRef.current) return;
    if (!Array.isArray(orderItems) || orderItems.length === 0return;
    const next = {};
    for (const it of orderItems) {
      const pizza = findPizzaByInput(it);
      const qty = Number(it?.qty ?? it?.quantity ?? 0) || 0;
      if (pizza && qty > 0) {
        next[pizza.id] = (next[pizza.id] || 0) + qty;
      }
    }
    if (Object.keys(next).length > 0) {
      setCart((prev) => {
        const merged = { ...prev };
        for (const [id, q] of Object.entries(next)) {
          merged[id] = (merged[id] || 0) + q;
        }
        return merged;
      });
      seededRef.current = true;
    }
  }, [orderItems]);

  const items = useMemo(() => {
    return PIZZAS.filter((p) => cart[p.id]).map((p) => ({
      ...p,
      qty: cart[p.id],
      lineTotal: p.price * cart[p.id],
    }));
  }, [cart]);

  const total = useMemo(() => items.reduce((sum, it) => sum + it.lineTotal, 0), [items]);
  const count = useMemo(() => items.reduce((sum, it) => sum + it.qty, 0), [items]);

  function addToCart(id) {
    setCart((prev) => ({ ...prev, [id]: (prev[id] || 0) + 1 }));
  }

  function goCheckout() {
    navigate("/checkout", { state: { items, total } });
  }

  return (
    <div className="min-h-screen bg-white text-gray-900">
      <header className="px-6 py-4 border-b">
        <h1 className="text-2xl font-bold">订购 Pizza</h1>
        <p className="text-sm text-gray-500">选择你喜欢的披萨,加入购物车并结算</p>
      </header>

      <main className="px-6 py-6">
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
          {PIZZAS.map((p) => (
            <div key={p.id} className="rounded-lg border shadow-sm overflow-hidden">
              <div className="aspect-video bg-gray-100">
                <img
                  src={p.image}
                  alt={p.name}
                  className="w-full h-full object-cover"
                  loading="lazy"
                />
              </div>
              <div className="p-4 flex items-center justify-between">
                <div>
                  <div className="font-semibold">{p.name}</div>
                  <div className="text-sm text-gray-600">{formatCurrency(p.price)}</div>
                </div>
                <button
                  className="inline-flex items-center rounded-md bg-orange-500 hover:bg-orange-600 text-white text-sm px-3 py-2"
                  onClick={() => addToCart(p.id)}
                >
                  加入购物车
                </button>
              </div>
            </div>
          ))}
        </div>
      </main>

      {/* 购物车汇总条 */}
      <div className="fixed left-0 right-0 bottom-0 border-t bg-white/95 backdrop-blur">
        <div className="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
          <div className="text-sm text-gray-700">
            已选 {count} 件 · 合计 <span className="font-semibold">{formatCurrency(total)}</span>
          </div>
          <button
            className="inline-flex items-center rounded-md bg-green-600 hover:bg-green-700 text-white text-sm px-4 py-2 disabled:opacity-50"
            onClick={goCheckout}
            disabled={count === 0}
          >
            购物车结算
          </button>
        </div>
      </div>
    </div>
  );
}

function CheckoutPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const items = location.state?.items || [];
  const total = location.state?.total || 0;

  function backToOrder() {
    navigate("/");
  }
  function payNow() {
    alert("已模拟付款,感谢你的订购!");
  }

  return (
    <div className="min-h-screen bg-white text-gray-900">
      <header className="px-6 py-4 border-b">
        <h1 className="text-2xl font-bold">付款页面</h1>
        <p className="text-sm text-gray-500">确认订单并完成付款</p>
      </header>

      <main className="px-6 py-6 mx-auto max-w-3xl">
        {items.length === 0 ? (

          <div className="text-center text-gray-600">
            购物车为空
            <div className="mt-4">
              <button
                className="rounded-md bg-gray-800 hover:bg-gray-900 text-white px-4 py-2"
                onClick={backToOrder}
              >
                返回订购
              </button>
            </div>
          </div>
        ) : (
          <div className="space-y-6">
            <div className="rounded-lg border overflow-hidden">
              <table className="w-full text-sm">
                <thead className="bg-gray-50">
                  <tr>
                    <th className="py-2 px-3 text-left">商品</th>
                    <th className="py-2 px-3 text-right">数量</th>
                    <th className="py-2 px-3 text-right">小计</th>
                  </tr>
                </thead>
                <tbody>
                  {items.map((it) => (
                    <tr key={it.id} className="border-t">
                      <td className="py-2 px-3">{it.name}</td>
                      <td className="py-2 px-3 text-right">{it.qty}</td>
                      <td className="py-2 px-3 text-right">{formatCurrency(it.lineTotal)}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
            <div className="flex items-center justify-between">
              <div className="text-gray-700">总计:</div>
              <div className="text-lg font-semibold">{formatCurrency(total)}</div>
            </div>
            <div className="flex items-center gap-3">
              <button
                className="rounded-md bg-gray-200 hover:bg-gray-300 text-gray-800 px-4 py-2"
                onClick={backToOrder}
              >
                返回订购
              </button>
              <button
                className="rounded-md bg-green-600 hover:bg-green-700 text-white px-4 py-2"
                onClick={payNow}
              >
                前往付款
              </button>
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

function RouterRoot() {
  return (
    <Routes>
      <Route path="/" element={<OrderPage />} />
      <Route path="/checkout" element={<CheckoutPage />} />
    </Routes>
  );
}

createRoot(document.getElementById("pizzaz-order-root")).render(
  <HashRouter>
    <RouterRoot />
  </HashRouter>
);
新建 MCP服务端
新建openai-apps-sdk-examples\test_pizzaz_server\文件夹添加 whoami和 order 工具,绑定 pizzaz-whoami-root 和 pizzaz-order-root 节点定义 order 工具的输入参数 schema
# 概览:
# 该文件实现了一个基于 FastMCP 的 MCP 服务器,通过 HTTP+SSE 暴露 “带 UI 的工具”。
# ChatGPT Apps SDK 会根据返回的 _meta 信息加载对应的前端组件。
# 主要模块:
# 1) 定义 PizzazWidget 元信息(模板 URI、HTML、标题等)
# 2) 列出工具/资源/资源模板(供客户端发现)
# 3) 处理 ReadResource/CallTool 请求(返回 HTML 组件 + 文本 + 结构化数据)
# 4) 启动 Uvicorn 应用,提供 SSE 和消息端点

from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Dict, List
# - mcp.types/FastMCP 提供 MCP 与服务端封装
import mcp.types as types
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, ConfigDict, Field, ValidationError


@dataclass(frozen=True)
class PizzazWidget:
    # Widget 元信息:每个组件的“工具描述”和其 HTML 
    identifier: str
    title: str
    template_uri: str
    invoking: str
    invoked: str
    html: str
    response_text: str


widgets: List[PizzazWidget] = [
    # Demo 小部件:用于生成工具与资源,引用持久化的 CSS/JS 资产,开发时可直接使用localhost:4444

    # whoami 小部件,显示“我是 pizza 助手”
    PizzazWidget(
        identifier="pizza-whoami",
        title="Who Am I",
        template_uri="ui://widget/pizza-whoami.html",
        invoking="Answering identity",
        invoked="Identity presented",
        html=(
            "<div id=\"pizzaz-whoami-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"http://localhost:4444/pizzaz-whoami.css\">\n"
            "<script type=\"module\" src=\"http://localhost:4444/pizzaz-whoami.js\"></script>"
        ),
        response_text="我是 pizza 助手",
    ),
    # 披萨订购小部件
    PizzazWidget(
        identifier="pizza-order",
        title="Order Pizza",
        template_uri="ui://widget/pizza-order.html",
        invoking="Opening the order page",
        invoked="Order page ready",
        html=(
            "<div id=\"pizzaz-order-root\"></div>\n"
            "<link rel=\"stylesheet\" href=\"http://localhost:4444/pizzaz-order.css\">\n"
            "<script type=\"module\" src=\"http://localhost:4444/pizzaz-order.js\"></script>"
        ),
        response_text="打开订购页面,可选择披萨并结算",
    ),
]

# 资源的 MIME 类型:Skybridge HTML(供 Apps SDK 识别渲染)
MIME_TYPE = "text/html+skybridge"


# 快速索引:按工具名或模板 URI 获取 Widget
WIDGETS_BY_ID: Dict[str, PizzazWidget] = {widget.identifier: widget for widget in widgets}
WIDGETS_BY_URI: Dict[str, PizzazWidget] = {widget.template_uri: widget for widget in widgets}



class PizzaInput(BaseModel):
    """Schema for pizza tools ( orderItems)."""

    # 订单条目列表,供 pizza-order 工具使用
    order_items: List[Dict[str, Any]] | None = Field(
        default=None,
        alias="orderItems",
        description=(
            "Optional order items list for the order page. Each item can include "
            "id/name and qty or quantity."
        ),
    )

    model_config = ConfigDict(populate_by_name=True, extra="forbid")

# FastMCP 配置:与 Node 版路径一致,支持无状态 HTTP 检视
mcp = FastMCP(
    name="pizzaz-python",
    sse_path="/mcp",
    message_path="/mcp/messages",
    stateless_http=True,
)

# 暴露给客户端的 JSON Schema
# 自定义结构化输入(在此项目中,定义orderItems来表示订单列表)
TOOL_INPUT_SCHEMA: Dict[str, Any] = {
    "type""object",
    "properties": {
        "orderItems": {
            "type""array",
            "description""Optional list of items to seed the cart.",
            "items": {
                "type""object",
                "properties": {
                    "id": { "type": ["string""null"] },
                    "name": { "type": ["string""null"] },
                    "qty": { "type": ["integer""null"] },
                    "quantity": { "type": ["integer""null"] },
                },
                "additionalProperties"True,
            },
        },
    },
    "required": [],
    "additionalProperties"False,
}



def _resource_description(widget: PizzazWidget) -> str:
    # 资源描述文案
    return f"{widget.title} widget markup"


def _tool_meta(widget: PizzazWidget) -> Dict[str, Any]:
    # Apps SDK 元数据:驱动 UI 渲染与状态文案
    return {
        "openai/outputTemplate": widget.template_uri,
        "openai/toolInvocation/invoking": widget.invoking,
        "openai/toolInvocation/invoked": widget.invoked,
        "openai/widgetAccessible"True,
        "openai/resultCanProduceWidget"True,
        "annotations": {
          "destructiveHint"False,
          "openWorldHint"False,
          "readOnlyHint"True,
        }
    }


def _embedded_widget_resource(widget: PizzazWidget) -> types.EmbeddedResource:
    # 将 HTML 外壳封装为嵌入资源返回
    return types.EmbeddedResource(
        type="resource",
        resource=types.TextResourceContents(
            uri=widget.template_uri,
            mimeType=MIME_TYPE,
            text=widget.html,
            title=widget.title,
        ),
    )


@mcp._mcp_server.list_tools()
async def _list_tools() -> List[types.Tool]:
    # 列出可用工具:每个 widget 为一个工具项
    return [
        types.Tool(
            name=widget.identifier,
            title=widget.title,
            description=widget.title,
            inputSchema=deepcopy(TOOL_INPUT_SCHEMA),
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


@mcp._mcp_server.list_resources()
async def _list_resources() -> List[types.Resource]:
    # 列出可读资源:用于 ReadResource 返回 HTML 外壳
    return [
        types.Resource(
            name=widget.title,
            title=widget.title,
            uri=widget.template_uri,
            description=_resource_description(widget),
            mimeType=MIME_TYPE,
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


@mcp._mcp_server.list_resource_templates()
async def _list_resource_templates() -> List[types.ResourceTemplate]:
    # 列出资源模板:匹配 uriTemplate
    return [
        types.ResourceTemplate(
            name=widget.title,
            title=widget.title,
            uriTemplate=widget.template_uri,
            description=_resource_description(widget),
            mimeType=MIME_TYPE,
            _meta=_tool_meta(widget),
        )
        for widget in widgets
    ]


async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult:
    # 处理读取资源:按 URI 返回 HTML 
    widget = WIDGETS_BY_URI.get(str(req.params.uri))
    if widget is None:
        return types.ServerResult(
            types.ReadResourceResult(
                contents=[],
                _meta={"error"f"Unknown resource: {req.params.uri}"},
            )
        )

    contents = [
        types.TextResourceContents(
            uri=widget.template_uri,
            mimeType=MIME_TYPE,
            text=widget.html,
            _meta=_tool_meta(widget),
        )
    ]

    return types.ServerResult(types.ReadResourceResult(contents=contents))


async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult:
    # 处理工具调用:校验输入、返回文本+结构化数据+嵌入的 HTML 资源与元信息
    widget = WIDGETS_BY_ID.get(req.params.name)
    if widget is None:
        return types.ServerResult(
            types.CallToolResult(
                content=[
                    types.TextContent(
                        type="text",
                        text=f"Unknown tool: {req.params.name}",
                    )
                ],
                isError=True,
            )
        )

    arguments = req.params.arguments or {}
    try:
        payload = PizzaInput.model_validate(arguments)
    except ValidationError as exc:
        return types.ServerResult(
            types.CallToolResult(
                content=[
                    types.TextContent(
                        type="text",

                        text=f"Input validation error: {exc.errors()}",
                    )
                ],
                isError=True,
            )
        )

    order_items = payload.order_items
    widget_resource = _embedded_widget_resource(widget)
    meta: Dict[str, Any] = {
        "openai.com/widget": widget_resource.model_dump(mode="json"),
        "openai/outputTemplate": widget.template_uri,
        "openai/toolInvocation/invoking": widget.invoking,
        "openai/toolInvocation/invoked": widget.invoked,
        "openai/widgetAccessible"True,
        "openai/resultCanProduceWidget"True,
    }

    structured: Dict[str, Any] = {}
    if widget.identifier == "pizza-order" and order_items is not None:
        structured["orderItems"] = order_items

    return types.ServerResult(
        types.CallToolResult(
            content=[
                types.TextContent(
                    type="text",
                    text=widget.response_text,
                )
            ],
            structuredContent=structured,
            _meta=meta,
        )
    )

# 显式注册处理器:确保请求路由到对应逻辑
mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request
mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource


# 创建支持 SSE 的 HTTP 应用(Starlette)
app = mcp.streamable_http_app()


try:
    from starlette.middleware.cors import CORSMiddleware

    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_methods=["*"],
        allow_headers=["*"],
        allow_credentials=False,
    )
except Exception:
    pass



if __name__ == "__main__":
    import uvicorn
# 入口:启动 Uvicorn,默认监听 8000 端口
    uvicorn.run("pizzaz_server_python.main:app", host="0.0.0.0", port=8000)
重新启动应用
接下来,我们重新启动应用。(要先把之前的应用 ctrl + c 停掉)
pnpm run build

pnpm run dev
uvicorn test_pizzaz_server.main:app --port 8000
在ChatGPT刷新连接器
测试使用
pizza 是谁
pizza 点单
详情参考:
发布会视频: https://www.bilibili.com/video/BV1bPxUzNEro/?vd_source=e95d9f4522e84e6008cdac1ce4d10a04
官方示例:https://github.com/openai/openai-apps-sdk-examples
官方文档:https://developers.openai.com/apps-sdk

扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。


53AI,企业落地大模型首选服务商

产品:场景落地咨询+大模型应用平台+行业解决方案

承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

添加专属顾问

回到顶部

加载中...

扫码咨询