OpenAI Apps SDK 教程(附pizza点单案例)Apps SDK 是 OpenAI 于 2025 年 10 月 10 日 在 DevDay 开发者大会 上正式发布的一套全新开发框架。它为开发者提供了在 ChatGPT 平台内构建与运行 Apps 的标准化途径,使得第三方应用可以嵌入 ChatGPT 的对话界面,拥有独立的前端交互体验与可视化组件。这或许意味着 ChatGPT 内 应用生态商业化 的开端,用户不仅能在对话中使用第三方 App,还能直接通过这些 App 完成消费与支付,实现资金与流量的闭环。一、亮点: Apps inside ChatGPT用户可在ChatGPT内直接与应用可视化交互,无需跳转 提供基于MCP标准构建的Apps SDK供开发者使用 Apps SDK 基于 MCP 标准,扩展了 MCP 以使开发者能够设计应用逻辑和界面。APP 存在于 ChatGPT 内部,用于扩展用户的功能,同时又不会打断对话流程,并通过轻量级卡片、轮播、全屏视图和其他显示模式无缝集成到 ChatGPT 界面,同时保持其清晰度、可信度和语音功能。APP 作为 MCP 扩展节点,每个第三方 App 的后端可以看作是一个 MCP 服务器,负责对外暴露能力。前端 UI 嵌入 ChatGPT 对话内, 并可以与 MCP 服务器双向通信。扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。 npminstall-gpnpm@latest-10 # 克隆项目到本地 gitclonehttps://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
到 官网https://dashboard.ngrok.com/注册ngrok账号,获取token,运行官网的配置token命令ngrokconfigadd-authtoken<token> 通过ngrok将本地服务器暴露到公网,获取临时URL在ChatGPT中 设置->应用与连接器->高级设置->启用开发者模式将ngrok提供的临时URL拼接上/mcp,在ChatGPT应用与连接器中创建一个新的连接器,名称填写pizza,MCP 服务器 URL填写拼接后的URL。现在,我们可以在ChatGPT中提问,例如:pizza 奶酪披萨地图。模型会返回可视化pizza地图,用户可以直接在ChatGPT中查看。扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。 接下来我们新建两个简单的示例来演示Apps SDK的使用。在/src下新建文件夹whoami,并新建文件index.jsx。// 示例:whoami 工具 最基础的工具示例 importReact, { useState }from"react"; import{ createRoot }from"react-dom/client"; //这里的App组件将作为chatgpt内的whoami工具的前端界面 functionApp(){ const[surprise, setSurprise] = useState(false); return( <divclassName="w-full h-full flex items-center justify-center p-6"> <divclassName="rounded-2xl border border-black/10 dark:border-white/10 p-8 shadow bg-white text-black text-center"> <h1className="text-2xl font-semibold">我是 pizza 助手</h1> <pclassName="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 && ( <divclassName="mt-4 text-5xl select-none"aria-label="烟花"> <spanclassName="inline-block animate-bounce">🎆</span> </div> )} </div> </div> ); } // 将组件绑定到 pizzaz-whoami-root 节点 createRoot(document.getElementById("pizzaz-whoami-root")).render(<App/>); 在/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、名称、图片、价格) constPIZZAS = [ { 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", }, ];
functionformatCurrency(n){ returnnewIntl.NumberFormat("zh-CN", { style:"currency", currency:"CNY"}).format(n); }
functionOrderPage(){ constnavigate = useNavigate(); const[cart, setCart] = useState({}); constseededRef = useRef(false); // 监听 toolOutput 与 toolInput consttoolOutput = useOpenAiGlobal("toolOutput"); consttoolInput = useOpenAiGlobal("toolInput"); constmergedProps = useMemo(() => ({ ...(toolInput ?? {}), ...(toolOutput ?? {}) }), [toolInput, toolOutput]); const{ orderItems = [] } = mergedProps; // 查找披萨 constfindPizzaByInput = (item) => { constname = item?.name; if(!name)returnnull; returnPIZZAS.find((p) => p.name === name); };
// 将初始条目注入购物车(chatgpt给的点单信息) useEffect(() => { if(seededRef.current)return; if(!Array.isArray(orderItems) || orderItems.length ===0)return; constnext = {}; for(constit of orderItems) { constpizza = findPizzaByInput(it); constqty = 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) => { constmerged = { ...prev }; for(const[id, q] of Object.entries(next)) { merged[id] = (merged[id] ||0) + q; } returnmerged; }); seededRef.current =true; } }, [orderItems]);
constitems = useMemo(() => { returnPIZZAS.filter((p) => cart[p.id]).map((p) => ({ ...p, qty: cart[p.id], lineTotal: p.price * cart[p.id], })); }, [cart]);
consttotal = useMemo(() => items.reduce((sum, it) => sum + it.lineTotal,0), [items]); constcount = useMemo(() => items.reduce((sum, it) => sum + it.qty,0), [items]);
functionaddToCart(id){ setCart((prev) => ({ ...prev, [id]: (prev[id] ||0) +1})); }
functiongoCheckout(){ 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 pacity-50" onClick={goCheckout} disabled={count ===0} > 购物车结算 </button> </div> </div> </div> ); }
functionCheckoutPage(){ constnavigate = useNavigate(); constlocation = useLocation(); constitems = location.state?.items || []; consttotal = location.state?.total ||0;
functionbackToOrder(){ navigate("/"); } functionpayNow(){ 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> ); }
functionRouterRoot(){ return( <Routes> <Route path="/"element={<OrderPage />} /> <Route path="/checkout"element={<CheckoutPage />} /> </Routes> ); }
createRoot(document.getElementById("pizzaz-order-root")).render( <HashRouter> <RouterRoot /> </HashRouter> ); 新建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__importannotations
fromcopyimportdeepcopy fromdataclassesimportdataclass fromtypingimportAny, Dict, List # - mcp.types/FastMCP 提供 MCP 与服务端封装 importmcp.typesastypes frommcp.server.fastmcpimportFastMCP frompydanticimportBaseModel, ConfigDict, Field, ValidationError
@dataclass(frozen=True) classPizzazWidget: # 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: widgetforwidgetinwidgets} WIDGETS_BY_URI: Dict[str, PizzazWidget] = {widget.template_uri: widgetforwidgetinwidgets}
classPizzaInput(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: # 资源描述文案 returnf"{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 外壳封装为嵌入资源返回 returntypes.EmbeddedResource( type="resource", resource=types.TextResourceContents( uri=widget.template_uri, mimeType=MIME_TYPE, text=widget.html, title=widget.title, ), )
@mcp._mcp_server.list_tools() asyncdef_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), ) forwidgetinwidgets ]
@mcp._mcp_server.list_resources() asyncdef_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), ) forwidgetinwidgets ]
@mcp._mcp_server.list_resource_templates() asyncdef_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), ) forwidgetinwidgets ]
asyncdef_handle_read_resource(req: types.ReadResourceRequest)-> types.ServerResult: # 处理读取资源:按 URI 返回 HTML widget = WIDGETS_BY_URI.get(str(req.params.uri)) ifwidgetisNone: returntypes.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), ) ]
returntypes.ServerResult(types.ReadResourceResult(contents=contents))
asyncdef_call_tool_request(req: types.CallToolRequest)-> types.ServerResult: # 处理工具调用:校验输入、返回文本+结构化数据+嵌入的 HTML 资源与元信息 widget = WIDGETS_BY_ID.get(req.params.name) ifwidgetisNone: returntypes.ServerResult( types.CallToolResult( content=[ types.TextContent( type="text", text=f"Unknown tool:{req.params.name}", ) ], isError=True, ) )
arguments = req.params.argumentsor{} try: payload = PizzaInput.model_validate(arguments) exceptValidationErrorasexc: returntypes.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] = {} ifwidget.identifier =="pizza-order"andorder_itemsisnotNone: structured["orderItems"] = order_items
returntypes.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: fromstarlette.middleware.corsimportCORSMiddleware
app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=False, ) exceptException: pass
if__name__ =="__main__": importuvicorn # 入口:启动 Uvicorn,默认监听 8000 端口 uvicorn.run("pizzaz_server_python.main:app", host="0.0.0.0", port=8000) 接下来,我们重新启动应用。(要先把之前的应用 ctrl + c 停掉)pnpmrun build
pnpm run dev
uvicorntest_pizzaz_server.main:app--port8000
|