CodeGym /课程 /ChatGPT Apps /模板是如何工作的:项目结构和关键文件

模板是如何工作的:项目结构和关键文件

ChatGPT Apps
第 2 级 , 课程 1
可用

1. 引言

ChatGPT App HelloWorld 项目并不是“CodeGym 的神秘黑盒,最好别碰”。它就是一个普通的 Next.js 项目,只是里面同时承载了:

  • 在 ChatGPT 内部渲染的前端,
  • 响应工具(tools)调用的 MCP 服务器,
  • 将这些与 ChatGPT 粘合在一起的配置。

如果不了解各部分的放置位置,通常会发生三种经典情况:

  1. 开发者不小心在服务器文件里写了 window,导致崩溃,从而开始厌恶整个技术栈。
  2. 想在 UI 中加按钮,却改错了 page.tsx(比如改了应用根页而不是小部件页),在 ChatGPT 里看不到变化。
  3. 误把 OPENAI_API_KEY 放进了客户端代码,密钥泄露到浏览器。

因此今天的目标是画一张地图:哪里是 UI,哪里是 MCP,哪里是配置;当你想要:

  • 调整小部件的外观;
  • 添加新的 tool;
  • 微调某些平台级设置(CORS、assetPrefix 等)。

2. 项目的高层解剖

ChatGPT App HelloWorld 的 Next.js 项目使用 App Router,围绕 app/ 目录组织。在同一页面树中并存:

  • 会在 ChatGPT 内渲染的小部件 UI,
  • 用于处理 tool 调用的 MCP endpoint。

典型的目录树(简化版,你的模板中文件夹名可能不同,但模式一致):

my-chatgpt-app/
├─ app/
│  ├─ api/                          // REST API
│  │  └─ time/                      // GET /api/time 返回服务器时间
│  │     └─ route.ts
│  ├─ hooks/                        // 官方 Apps SDK 的 hooks 集合
│  │  ├─ use-call-tool.ts
│  │  ├─ use-display-mode.ts
│  │  └─ use-open-external.ts
│  ├─ mcp/                          // MCP 服务器:ChatGPT 调用 tools 时会访问这里
│  │  └─ route.ts
│  ├─ globals.css                   // 应用的根级 globals.css
│  ├─ layout.tsx                    // 应用的根级 layout
│  └─ page.tsx                      // 在 ChatGPT 中渲染的小部件页面
├─ public/                          // 静态资源:图标、清单等
├─ next.config.ts                   // Next.js 配置与 Apps 特定设置(assetPrefix 等)
├─ proxy.ts                         // 在 iframe 中工作的 CORS/头(原 middleware.ts)
├─ package.json                     // 项目依赖
├─ tsconfig.json                    // TypeScript 配置
└─ .env.local                       // 密钥:OPENAI_API_KEY 等

如果有多个小部件,通常不放在 app/page.tsx,而是放在 app/widget/page.tsx。但逻辑不变:仍然是一个小部件页面, 以及一个充当 MCP 服务器角色的 endpoint。

一个便于记忆的比喻:你的仓库就像“双面神雅努斯”。

  • 一张“脸”是路径 /mcp,当 ChatGPT 想调用工具时会访问这里;
  • 另一张“脸”是路径 /widget(或 /), 当模型决定展示你的 UI 时,会在 iframe 中加载它。

为避免混淆,我们在脑中固定三组文件:

  1. UI 层——与 React/Next 页面相关的一切 (app/widget、组件、样式)。
  2. MCP 层——app/mcp/route.ts 及其使用的文件。
  3. 粘合层与配置——next.config.tsproxy.ts.env.localpackage.jsontsconfig.json

下面我们逐一走查这些层。

3. 小部件位于哪里:app/widget 和/或 app/page.tsx

先从你最常改动的地方开始——小部件,也就是会在 ChatGPT 内部可见的 UI。

大多数现代项目中会有以下两种之一:

  • 目录 app/widget/page.tsx——小部件位于单独的前缀 /widget 下,
  • 或根页面 app/page.tsx——小部件与根页面相同。

小部件文件的主要特征:

  • 文件最顶部有 'use client',因为组件在浏览器中运行, 会与 window 和 Apps SDK 交互;
  • 它是一个普通的 React 组件,负责渲染标记,并且(稍后在课程中)与 window.openai 通信。

最简单的教学小部件示例(你的项目中可能已经有非常相似的代码):

// app/widget/page.tsx
'use client';

import React from 'react';

export default function WidgetPage() {
  return (
    <main className="p-4">
      <h1 className="text-xl font-semibold">
        HelloWorld — ChatGPT App
      </h1>
      <p className="text-sm text-gray-500">
        我们将在这里构建小部件的 UI。
      </p>
    </main>
  );
}

如果你的模板把小部件直接放在 app/page.tsx,代码基本一致, 只是没有中间的 widget 文件夹。

请注意以下几点。

首先,指令 'use client' 是必需的: 小部件要读/写 window.openai, 监听事件等——这些只能在客户端组件中完成。 如果去掉它,Next 会尝试将页面作为服务器端渲染,你会得到类似 “window is not defined” 的错误。

其次,它就是一个完全不神奇的 React 组件。你可以:

  • 把它拆分到 components/ 子组件中,
  • 使用 Tailwind 或任意其他 CSS 方案,
  • 接入 context、hooks 等等。

第三,之后你会在这里:

  • 读取 window.openai.toolInputwindow.openai.toolOutput, 来渲染真实数据,
  • 通过 window.openai.setWidgetState 保存 widgetState
  • 调用 openExternalcallTool 等运行时方法。

目前只需记住:如果你想修改视觉界面——十有八九要去 app/widget/page.tsxapp/page.tsx

4. 根级 layout:作为整个应用“框架”的 app/layout.tsx

下一个重要文件是 app/layout.tsx。它:

  • 定义 HTML 结构(<html><body>),
  • 引入全局样式(globals.css),
  • 经常会初始化 Apps SDK 的“bootstrap”(一个监听 window.openai 并把数据传给 React 的包装)。

一个简化示例:

// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NextChatSDKBootstrap baseUrl={baseURL} />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
        {children}
      </body>
    </html>
  );
}

这里的 NextChatSDKBootstrap 只是示意命名,你的模板里可能是 OpenAIAppProvider 或其他组件。它的职责通常只有一个:在 React 树与 Apps SDK 运行时之间建立连接, 订阅全局数据(themedisplayModetoolInput 等)并下发给子组件。

一个重要的实践结论:如果你需要接入全局上下文、样式或 UI 库 (例如 shadcn/ui),那么位置几乎总是 app/layout.tsx(或者放在 app/widget 下的 layout,用于仅对小部件生效的设置与组件)。

解析 NextChatSDKBootstrap

NextChatSDKBootstrap 我是从 Vercel 的官方模板里借鉴的。 如果你不清楚,正是他们创建并发展了 Next。他们网站上有一篇不错的文章: 在 ChatGPT 中运行 Next 的深度解析。 还有一个 Starter Template。 虽然个别地方有点过时,但我认为他们很可能会持续维护其时效性。

我们提炼出 NextChatSDKBootstrap 带来的 5 件关键事情:

  • 1. 修复水合问题
    原因在于 ChatGPT 会先把你的小部件 HTML 加载到它的服务器上,进行清理与打补丁。 结果导致水合机制报错,在控制台抛出大量警告。这可能会影响你的审核通过。
  • 2. 改写浏览器历史
    你的小部件在 ChatGPT 的专用域名下以 iframe 方式加载。 如果使用你自己的域名,会破坏沙箱。因此浏览器历史里只保存不含域名的路径。
  • 3. 重写 fetch() 函数
    你在相对路径(无域名)上的 fetch() 在小部件中不会工作, 因为 iframe 的域不同。所以我们会替换 fetch(), 把无域名请求转发到正确的 URL;如果显式写了域名,则保持不变。
  • 4. 点击链接可正常工作
    如果链接在 iframe 内部打开,ChatGPT 不会认可。 因此我们添加代码监听链接点击,并通过 openExternal() 在外部窗口打开。
  • 5. 设置 head base(已弃用)
    此前这段代码会往 <head> 里添加 <base>, 但现在已不可用。沙箱会重置任何设置的 base, 因此建议为所有内容使用绝对链接:脚本、资源、字体、API 等。

5. MCP 服务器:app/mcp/route.ts

现在我们来到“双面神”的另一面——与 ChatGPT 通过 MCP 通信的服务器

文件 app/mcp/route.ts 是一个普通的 App Router Route Handler,它:

  • 接收来自 ChatGPT 的 HTTP 请求(通常是带 MCP 格式 JSON 负载的 POST),
  • 把它们传给 MCP 服务器(基于 @modelcontextprotocol/sdk 或一个薄封装),
  • 返回 MCP 格式的 JSON 响应。

有两种写法:要么直接用原生 MCP SDK,要么用 Next/Vercel 的一些类做封装让体验更友好。

这是使用纯 TS MCP SDK 的一个版本:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// 1. 创建 MCP 服务器
const server = new McpServer({
  name: "simple-mcp-server",
  version: "1.0.0",
});

// 2. 注册 MCP Resources
// 3. 注册 MCP Tools

// 4. HTTP 传输
const transport = new HttpServerTransport({
  port: 3001,
  path: "/mcp",
});

// 5. 启动服务器
await server.connect(transport);

但更推荐借助一些现成的类,让开发更顺手:

// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";

const handler = createMcpHandler(async (server) => {
  const gateway = new McpGateway(server);
  await gateway.initialize();
  gateway.registerResources();
  gateway.registerTools();
});

export const GET = handler;
export const POST = handler;

这里的 McpGatewayMcpServer 的包装类, 用 SDK 在某处(比如 lib/mcp/server.ts)创建。 在我们的例子中,它全部放在 app/mcp/route.ts。我们来完整拆解这个文件里的内容。

type ContentWidget

文件开头定义了类型 ContentWidget。它包含小部件的全部数据,并用于两个地方: 把小部件注册为 mcp-resource,以及当 mcp-tool 返回 metadata 时指明应使用哪个小部件展示它返回的数据。

type ContentWidget = {
  id: string;            // 唯一名称/key
  title: string;         // Title
  description: string;   // Description
  templateUri: string;   // 小部件的唯一 URI,可以是任意值。不会影响其他行为。
  invoking: string;      // 小部件加载时的标题文案
  invoked: string;       // 小部件加载完成后的标题文案
  html: string;          // 小部件的全部 html 代码
  widgetDomain: string;  // 小部件的“域名”。不会影响其他行为。
};

class McpGateway

这是对 McpServer 的包装类,用于简化若干事情。包含 6 个方法:

  • initialize() —— 在这里加载我们的小部件 HTML
  • registerResources() —— 将小部件注册为 mcp-resources
  • registerTools() —— 将函数注册为 mcp-tools
  • widgetMeta() —— 返回小部件的元数据
  • getAppsSdkCompatibleHtml() —— 加载小部件 HTML 并做少量补丁
  • makeImgUrlsAbsolute() —— 补丁 HTML:把图片链接改为绝对地址

逐个讲解如下:

public async initialize()

该方法从网络加载小部件的 HTML 代码,并填充 ContentWidget 类型的对象。

{
  id: "hello_world",                         // 小部件的唯一 key
  templateUri: "ui://widget/hello_world.html", // 小部件的唯一 URI。前缀 "ui:" 没有特殊含义。
  title: "HelloWorld Widget",               // 小部件名称
  description: "Displays the HelloWorld widget", // 给 LLM 的说明:小部件做什么
  invoking: "Loading widget...",            // 小部件加载时的标题文案
  invoked: "Widget loaded",                 // 小部件加载完成后的标题文案
  html: htmlWidget,                         // 小部件的 HTML
  widgetDomain: baseURL,                    // 小部件的“域名”。目前没有实质影响。
}

public registerResources()

将小部件注册为 mcp-resources。会调用 server.registerResource() 方法,传入 4 个参数:

  • MCP 资源的 id/key
  • 资源的 URI(这是 MCP 协议需要的;对小部件来说相当于唯一地址)
  • MCP 资源的元数据
  • 返回 MCP 资源的函数

小部件的元数据

{
  title: widget.title,                 // 资源/小部件名称
  description: widget.description,     // 资源/小部件描述
  mimeType: "text/html+skybridge",     // 重要!只有这种 html 才会以小部件形式展示
  _meta: {
    "openai/widgetDescription": widget.description, // 小部件描述
    "openai/widgetPrefersBorder": true,            // 让 ChatGPT 绘制小部件边框
  },
}

把小部件作为 MCP 资源返回

{
  uri: uri.href,                        // 我们的 URI(来自参数 uri)
  mimeType: "text/html+skybridge",      // 重要!只有这种 html 才会以小部件形式展示
  text: widget.html,                    // 小部件的 HTML
  _meta: {
    "openai/widgetDescription": widget.description, // 小部件描述
    "openai/widgetPrefersBorder": true,            // 让 ChatGPT 绘制小部件边框
    "openai/widgetDomain": widget.widgetDomain,    // 小部件的“域名”。目前没有实质影响。
    "openai/widgetCSP": {                          // 重要!小部件可访问的域名:
      connect_domains: [                           // 用于连接的域名(fetch 等)
        baseURL,
        "https://codegym.cc",
      ],
      resource_domains: [                          // 资源域名(css/fonts/img)
        baseURL,
        "https://codegym.cc",
        "https://cdn.tailwindcss.com",
        "https://persistent.oaistatic.com",
        "https://fonts.googleapis.com",
        "https://fonts.gstatic.com"
      ]
    }
  },
}

将来我们还会多次提到 openai/widgetCSP,不过现在先强调两点:

  • connect_domains —— 域名列表用于:
    • fetch()
    • 加载脚本
    • openExternal()
  • resource_domains —— 域名列表用于:
    • 图片
    • CSS
    • 字体

理论上你可以写上 200 个域,但是否能带着这样的列表通过审核——就不好说了。

我也研究了部分已发布应用的这些参数,发现里面有 amplitude.com。 这也是个好消息。我认为良好的数据分析对谁都不坏。

public registerTools()

将函数注册为 mcp-tools。会调用 server.registerTool() 方法,传入 3 个参数:

  • MCP tool 的 id/key
  • MCP tool 的元数据
  • 返回 MCP tool 的函数

工具的元数据

下面列表里的参数都很重要。细节我会在接下来的讲次中展开。

{
  title: widget.title,                               // 工具名称
  description: "Returns HelloWorld widget",          // 重要!工具的功能说明
  inputSchema: z.object({}).describe("No inputs"),   // 工具参数的 Schema。可用 Zod
  _meta: this.widgetMeta(widget),                    // 小部件的元数据:展示哪个小部件
  annotations: {
    destructiveHint: false,                          // 方法有破坏性/需确认
    openWorldHint: false,                            // 方法会改动第三方服务
    readOnlyHint: true                               // 方法不做任何修改
  },
}

执行重要事情的函数

async (input, extra) => {
  // 1. 参数校验
  // 2. 执行重要操作
  return {
    content: [{ type: "text", text: "HelloWorld MCP-tool" }], // 给 AI 的结果描述
    structuredContent: {                                      // 重要!这就是结果的 JSON。
      timestamp: new Date().toISOString()                     // 可以包含任意数据。
    },
    _meta: this.widgetMeta(widget),                           // 展示该 JSON 的小部件的元数据
  };                                                          // 可省略——省略则不显示小部件
}

private widgetMeta(widget: ContentWidget)

返回小部件的元数据——ChatGPT 会据此决定展示哪个小部件来显示 JSON 结果。

{
  "openai/outputTemplate": widget.templateUri,            // 小部件的 URI
  "openai/toolInvocation/invoking": widget.invoking,      // 小部件加载中时的标题文案
  "openai/toolInvocation/invoked": widget.invoked,        // 小部件加载完成时的标题文案
  "openai/widgetAccessible": true,                        // 可从小部件内调用 MCP tool
  "openai/resultCanProduceWidget": true,                  // MCP tool 会返回小部件
}

想单独说明一下 "openai/outputTemplate" 这个看似简单的东西。 在 MCP 协议中有 3 个实体(你会在第 6 模块详细了解):

  • MCP Resources
  • MCP Templates
  • MCP Tools

这里的 "openai/outputTemplate" MCP Templates 没有任何关系。 MCP Templates 在 ChatGPT Apps 中完全没有用到。这里的 template 一词来源如下:

小部件被设计为展示 JSON 的模板。MCP tool 返回某个 JSON,模型展示小部件,通过 ToolOutput 参数把 JSON 传给小部件, 小部件再把这个 JSON 漂亮地呈现出来。outputTemplate 只是“小部件”的同义词。

这部分先到这里。我们会在第 4 模块更详细地讲:如何描述工具、JSON Schema 与处理器。 现在只需明白:凡是与工具(tools)和业务逻辑相关的内容——都去 app/mcp/route.ts 附近找。

6. 配置与“粘合层”:next.config.tsmiddleware.ts.env 及其伙伴

现在来看看让你的 Next.js 项目能在 ChatGPT 的 iframe 中正常工作、 并能通过 HTTPS 隧道(ngrok、Cloudflare Tunnel 等;我们之后会专门讲隧道)被 ChatGPT 访问所需的关键文件集。

next.config.ts

这个文件除了 Next.js 的常规配置外,常见还会配置:

  • assetPrefix —— 让静态资源(/_next/ 下的 JS、CSS) 不从 ChatGPT 的域加载,而是从你的开发 URL(隧道或 Vercel)加载;
  • 模板所需的其他特定设置(例如 Next 16 的实验性 flag)。

实际上,它就是导出一个包含相应字段的 nextConfig。 对本讲而言最重要的一点是:如果在 ChatGPT 中小部件加载不了 CSS/JS,罪魁祸首往往是 assetPrefix

proxy.ts(原 middleware.ts

该文件在来自 ChatGPT 的请求与路由之间插入 middleware 层。在模板中它通常会:

  • 设置 CORS 头,让 ChatGPT 的 iframe 有权访问你的服务器;
  • 有时也会为 React Server Components 添加额外的头。

现在无需掌握全部细节。只需记住:如果 ChatGPT 报 CORS,或你在 DevTools 看到奇怪的访问禁止错误,请查看 proxy.ts

.env

.env(或 .env.local)是放置密钥和环境参数的地方:

  • OPENAI_API_KEY(如果 MCP 服务器本身会访问 OpenAI API),
  • 你的内部 API 地址,
  • 第三方服务的 token 等等。

有个重要细节:在 Next.js 中,以 NEXT_PUBLIC_ 开头的环境变量会自动打包进 JS,并在浏览器中可用。 千万不要这样处理 OPENAI_API_KEY;密钥应该存在于服务器端变量。

package.jsontsconfig.json

package.json 里你会看到:

  • Next.js、React、Apps SDK、MCP SDK 等依赖的版本;
  • devbuildstart 脚本,有时还有辅助命令(lint、format 等)。

tsconfig.json 中是你熟悉的 TypeScript 配置:

  • 路径别名(@/lib@/components),
  • 严格模式,
  • 编译目标。

就本课程而言,最重要的是理解:该模板使用的是常规的 TypeScript 技术栈,你可以以标准方式扩展它

7. 开发者的“项目快速导航”

我们固定一下在做常见任务时该去哪里找。不用列大清单,就当作几个迷你场景。

如果你想修改小部件中的文字/按钮,打开小部件 UI 的文件:要么是 app/widget/page.tsx,要么是 app/page.tsx——取决于模板。 在那里你修改 JSX、添加新组件、接入设计系统。你也会在这里使用 Apps SDK 运行时 (window.openai 或便捷的 hooks)来展示数据。

如果要添加一个在服务器上执行操作的新按钮,你仍然从 UI 文件开始。 小部件中的按钮在点击时会调用 window.openai.callTool, 而该工具的实现会加到 MCP 服务器的配置中,也就是 app/mcp/route.ts 附近的代码。 UI ↔ tool 逻辑的串联我们会在第 4 模块及之后详细拆解。

当你想让 ChatGPT 学会新功能(比如“搜索旅行”或“选品”), 你要去 MCP 层(从 app/mcp/route.ts 引入的那些文件)。 在那里注册一个带 JSON Schema、描述与处理器的新 tool。小部件随后可以通过 window.openai.toolOutput 读取结果并进行友好展示。

如果静态资源失效或小部件只在 ChatGPT 中显示异常,而本地一切正常, 请回忆粘合层。首先检查 next.config.ts (尤其是 assetPrefix)以及 middleware.ts/proxy.ts(CORS)。 如果你刚换了隧道、URL,或部署到了 Vercel,这些设置的正确性至关重要。

最后,如果你怀疑是密钥或环境问题,请查看这三个文件—— .env.localpackage.json (确认实际使用了哪些依赖与脚本)和开发服务器日志。 正是这一组合保证 MCP 能访问到必要的密钥和服务。

8. 小实践:动手熟悉文件系统

理论归理论,动手更扎实。你可以在编辑器/IDE 里立即按以下步骤操作。

在你的项目中打开 app 目录,找到负责小部件的文件。 如果模板使用 app/page.tsx,你会在其中看到类似 “HelloWorld — ChatGPT App” 的字样或欢迎文本。如果没有单独的小部件目录,请打开 app/page.tsx,确认里面有 'use client' 和一些 JSX 标记。

然后找到 app/mcp/route.ts。留意它引入了哪些模块: 通常要么直接使用 MCP SDK,要么调用 lib/mcp/* 下的辅助函数。 评估这层“薄封装”的程度——理想情况下几乎没有业务逻辑,只有“接收 JSON → 交给服务器 → 返回 JSON”。

之后看看 next.config.tsproxy.ts/middleware.ts。 不必看懂全部内容,只需确认:

  • next.config.ts 负责 Next 的配置,包括构建与静态资源的规则;
  • proxy.ts 会介入 HTTP 请求(几乎可以看到它在处理各种头)。

最后打开 .env.env.local,确保你的密钥确实放在这里,而不是写进代码。 如果你看到 NEXT_PUBLIC_OPENAI_API_KEY——那就是一个很好的改正时机,趁现在还只是本地开发。

9. 可视化图示:ChatGPT 如何与模板交互

为让全貌更清晰,看看这个简单的流程:

flowchart TD
    U[ChatGPT 中的用户] -->|输入请求| M[ChatGPT 模型]

    M -->|调用 tool| MCP["你的 MCP endpoint
app/mcp/route.ts"] MCP -->|"MCP 的 JSON 响应(structuredContent、_meta、UI 链接)"| M M -->|决定展示 UI| WIDGET_URL["小部件的 URL
(/widget 或 /)"] WIDGET_URL -->|iframe| W[你的小部件
app/page.tsx] W -->|读取 window.openai.toolOutput
+ widgetState| U

这里要注意,发起方几乎总是 ChatGPT 模型本身,而不是传统 Web 应用中的用户浏览器。 你的 app/mcp/route.tsapp/widget/page.tsx 就是同一个 Next.js 项目的两道“门”:一道给机器人(MCP),一道给 UI。

只要牢记这张项目地图(小部件 → MCP 层 → 配置)并有意识地避开上述坑, 后续课程中你就能把精力集中在 App 的逻辑与 UX 上,而不是在“那个把一切都搞坏的文件”里反复摸索。

10. 使用模板结构时的常见错误

错误 №1:把小部件当成网站的普通页面。
有时开发者在模板里同时看到 app/page.tsxapp/widget/page.tsx, 改错了文件,结果在 ChatGPT 中看不到变化。小部件是被用作 MCP 工具的 outputTemplate/iframe 的那个页面。 如果你改的是另一个路由,ChatGPT 根本不会察觉。务必查看模板的 README,确认哪个 URL 被指定为小部件。

错误 №2:在 MCP 的服务器端文件里写客户端代码(windowdocument)。
文件 app/mcp/route.ts 以及它引入的所有内容都在服务器执行。任何使用 window 或 DOM API 的尝试都会导致运行时崩溃。如果要在 UI 做点什么,几乎肯定应该放在 app/widget 或其他客户端组件中。MCP 层是纯后端:请求、数据库、外部 API, 以及生成结构化响应。

错误 №3:忽视 assetPrefix 和 CORS 设置。
在本地的 localhost:3000 一切完美,但通过隧道在 ChatGPT 打开 App 时——样式丢失、 JS 不加载、控制台出现大量 CORS 错误。很多时候原因是 next.config.tsmiddleware.ts/proxy.ts 的配置没有考虑新的公共 URL, 或在重构时被无意破坏。修改这些文件时,请始终记住你的代码将运行在 ChatGPT 域的 iframe 里, 而不是直接在 localhost

错误 №4:不把密钥放在 .env 中,而是直接写在代码里,或放在 NEXT_PUBLIC_* 变量中。
OPENAI_API_KEY 藏在 const apiKey = 'sk-...' 之类的 app/widget/page.tsx 中是最糟糕的做法:密钥会出现在 JS 包里,任何用户都能拿到。 几乎同样糟糕的是使用 NEXT_PUBLIC_OPENAI_API_KEY,因为 NEXT_PUBLIC_ 前缀保证它会进入浏览器。请始终把密钥放到 .env(不带此前缀)里,并且只在服务器端使用(MCP 服务器、后端函数)。

错误 №5:把模板想得“太聪明”,不敢修改。
有时开发者把官方 starter 当作神圣不可触碰的东西:“最好别改,免得破坏集成”。 结果把所有代码都写在旁边,架构更复杂,还是踩同样的坑。其实模板不过是一个为 Apps SDK 做了少量配置的 Next.js 项目。 理解 app/ 既包含 UI 又包含 MCP,而其他都是常规配置,这会让你轻松很多: 你会像操作一个熟悉的 React/Next 项目那样工作,而不是对着“神秘盒子”发愁。

错误 №6:试图把所有问题都“在小部件层面”解决。
有时会想在 UI 里做完所有事情:业务逻辑、访问数据库、外部 API 请求。在 ChatGPT Apps 的语境下这尤为糟糕: 小部件运行在非常严格的沙箱中,看不到你的密钥,并且强烈依赖 window.openai。 如果要做严肃的事——请放在 MCP 层与后端服务中;小部件应当是轻薄的展示层,负责渲染结构化数据,并在必要时触发工具。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION