微信扫码
添加专属顾问
我要投稿
OpenAI Apps SDK 重磅发布,带你玩转ChatGPT内应用开发,实现商业闭环!核心内容: 1. Apps SDK的核心功能与商业价值 2. 官方示例项目实战演示(Python版) 3. 从零构建自定义应用的全流程指南
第三方应用可直接集成到ChatGPT对话界面中
用户可在ChatGPT内直接与应用可视化交互,无需跳转
提供基于MCP标准构建的Apps SDK供开发者使用
扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。
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 -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 config add-authtoken <token>
ngrok http 8000
扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。
// 示例: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 工具 含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 === 0) return;
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>
);
# 概览:
# 该文件实现了一个基于 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)
pnpm run build
pnpm run dev
uvicorn test_pizzaz_server.main:app --port 8000
扫码加入 领取 Apps SDK 源码,还有持续更新的 Agent / RAG / 多模态 应用落地实战案例,带你深入学习。
53AI,企业落地大模型首选服务商
产品:场景落地咨询+大模型应用平台+行业解决方案
承诺:免费POC验证,效果达标后再合作。零风险落地应用大模型,已交付160+中大型企业
2025-10-13
2025 AI Agent 元年:你还在用 AI 聊天,别人已靠“智能体”成为“超级个体”
2025-10-13
为何底层数据湖决定了 AI Agent 的上限?
2025-10-13
从需求到运维:证券领域LLM增强型DevOps平台建设实践
2025-10-13
Qoder + ADB Supabase :5分钟GET超火AI手办生图APP
2025-10-13
字节正在内测一款全新的语音输入法
2025-10-13
从原理到实践,一份给企业管理者的AI大模型认知指南
2025-10-13
一文带你了解模型量化、剪枝和蒸馏
2025-10-13
AI测试工具的“三重奏”:从数据到Agent的工程化路径
2025-08-21
2025-08-21
2025-08-19
2025-09-16
2025-07-29
2025-09-08
2025-08-19
2025-09-17
2025-09-29
2025-08-20
2025-10-13
2025-10-09
2025-10-09
2025-10-07
2025-10-04
2025-09-30
2025-09-29
2025-09-28