返回顶部
热门问答 更多热门问答
技术文章 更多技术文章

全网首发 OpenAI Apps SDK 使用教程

[复制链接]
链载Ai 显示全部楼层 发表于 前天 22:26 |阅读模式 打印 上一主题 下一主题
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
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
安装ngrok(若有ngrok,跳到下一步)
在Microsoft Store中下载ngrok

到 官网https://dashboard.ngrok.com/注册ngrok账号,获取token,运行官网的配置token命令
ngrokconfigadd-authtoken<token>
通过ngrok将本地服务器暴露到公网,获取临时URL
ngrokhttp8000
启用开发者模式
在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 工具 最基础的工具示例
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/>);
新建 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、名称、图片、价格)
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 disabledpacity-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>
);
新建 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__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
在ChatGPT刷新连接器
测试使用
pizza 是谁
pizza 点单


回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

链载AI是专业的生成式人工智能教程平台。提供Stable Diffusion、Midjourney AI绘画教程,Suno AI音乐生成指南,以及Runway、Pika等AI视频制作与动画生成实战案例。从提示词编写到参数调整,手把手助您从入门到精通。
  • 官方手机版

  • 微信公众号

  • 商务合作

  • Powered by Discuz! X3.5 | Copyright © 2025-2025. | 链载Ai
  • 桂ICP备2024021734号 | 营业执照 | |广西笔趣文化传媒有限公司|| QQ