CodeGym /课程 /ChatGPT Apps /第一个冒烟测试:“Hello widget”和 openExternal

第一个冒烟测试:“Hello widget”和 openExternal

ChatGPT Apps
第 2 级 , 课程 4
可用

1. 什么是 ChatGPT App 的冒烟测试

在常见的 Web 开发语境中,冒烟测试就是最基本的检查——“系统还活着吗?” 页面能打开,按钮不会崩,没出现致命错误。

在 ChatGPT Apps 的世界里,冒烟测试会更有趣一些,因为链路上同时涉及多个环节:

  1. 你的小部件代码(React/Next.js)。
  2. Next.js 开发服务器。
  3. 隧道(ngrok/Cloudflare)。
  4. ChatGPT,它会创建 iframe 并把你的小部件加载到聊天里。

对我们来说,一个理想的冒烟测试意味着:

  • 小部件能在 ChatGPT 内部无错误地渲染;
  • 基础的交互正常(例如点击按钮后能打开外部链接);
  • 浏览器控制台和开发服务器日志里没有一片“红色雪崩”。

重要提示:在这个阶段,我们还不检查 MCP 工具,不做压力测试,也不计算 Token 成本。我们的目标朴素而务实:证明“代码 → Next.js → 隧道 → ChatGPT → 用户”这条链路能够闭环。

可以把它想象成这样一张表:

检查什么 如何判断一切正常
小部件渲染 在 ChatGPT 中能看到我们的 UI,而不是“损坏的 iframe”
ChatGPT ↔ 我们的服务器连通性 没有“无法加载应用”的错误
沙箱中的 JS 运行 处理器 onClick 确实会执行
能否打开外部链接 按钮会用指定的 URL 打开新标签/窗口

2. 我们的教学 App:简单的“Hello GiftGenius”

在本课程中,我们会逐步构建 GiftGenius——一个礼物选品助手。在当前这一步,它还不会选礼物,但至少可以礼貌地打个招呼,并展示一个“了解更多”的链接。

我们需要一个最小但真实的小部件:没有复杂逻辑,但包含可运行的 React 代码。

一个最简单的小部件组件可以长这样(名称和样式你可以自行调整,这里先使用课程计划中的基础版):


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

export default function GiftGeniusWidget() {
  return (
    <main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
      <h1 style={{ fontSize: 24, marginBottom: 8 }}>
        Hello from GiftGenius
      </h1>
      <p style={{ marginBottom: 16 }}>
        这是你的第一个 ChatGPT App。接下来我们会教它挑选礼物。
      </p>
    </main>
  );
}

几个重要的注意点。

首先,文件开头的指令 'use client'; 会把组件标记为客户端组件。没有它,Next.js 会把文件当作服务端组件处理,你将无法使用 window、事件处理器 onClick,以及任何浏览器 API。

其次,这就是一个普通的 React 组件。你看不到任何“Apps SDK 的魔法”——这很坦诚。它之所以最终出现在 ChatGPT 里,所有“魔法”都藏在 MCP 服务器的配置以及那个返回小部件 URL 的工具里。我们稍后再处理这些内容,现在只关心 UI。

3. 把小部件嵌入模板并运行

在用于 Apps SDK 的官方 Next.js 模板里,小部件页面通常已经存在;你可以直接修改,或者按需在相应路由下新建页面(例如 /widget)。

假设你已经有了 app/widget/page.tsx,并把其内容替换为上面的代码。接下来链路是这样的:

  1. 你保存文件。
  2. Next.js 开发服务器(通过 npm run dev 已经启动)会重启相关模块,HMR 刷新页面。
  3. 通过隧道,你的公网 HTTPS URL 在相同路径 /widget 上开始提供更新后的 UI。

有两种方法进行验证。

先用“老派方式”——在本地浏览器打开:

http://localhost:3000/widget

你会看到相同的 Hello from GiftGenius。没错,这还不是 ChatGPT,只是用来确认你的 Next.js 应用的 UI 是活的。

然后——通过隧道。拿到发放的 URL(类似 https://witty-cat.ngrok-free.app),在后面加上 /widget,并在普通浏览器中打开:

https://witty-cat.ngrok-free.app/widget

如果一切顺利,页面看起来应完全一致。说明“Next.js → 隧道 → 你的浏览器”这段链路正常,现在只差把 ChatGPT 插到它们之间。

4. 在 ChatGPT 内部检查小部件

ChatGPT 的 Dev Mode 本质上做三件事:创建一个 iframe,给它设置指向你公网 URL 的 src,然后让这个 iframe 在聊天消息里存活。

简化后的事件顺序如下:

sequenceDiagram
    participant Dev as 你(Dev)
    participant Next as Next.js 开发服务器
    participant Tun as 隧道(HTTPS)
    participant GPT as ChatGPT
    participant User as 用户

    Dev->>Next: npm run dev (http://localhost:3000)
    Dev->>Tun: 启动指向 3000 端口的隧道
    GPT->>Tun: GET https://.../widget
    Tun->>Next: 代理到 http://localhost:3000/widget
    Next-->>Tun: 小部件的 HTML + JS
    Tun-->>GPT: 返回 HTML/JS 响应
    GPT->>User: 渲染带小部件的 iframe

要看到结果,你需要:

  1. 在浏览器中打开 ChatGPT,选择所需的模型(通常是 GPT‑5.1,或 Dev Mode 默认指定的模型)。
  2. 显式选择你的应用(通过 Apps/Developer 菜单),或用类似“启动 GiftGenius 应用”的话语来“召唤”它。
  3. ChatGPT 会调用你的 App,MCP 服务器返回的响应中包含 UI 链接(就是 /widget),随后你的小部件会出现在聊天消息里。

如果一切正常,你会在 ChatGPT 内部看到熟悉的标题“Hello from GiftGenius”。到这一步,冒烟测试几乎完成:iframe 能渲染,“Next.js → 隧道 → ChatGPT”这段链路是通的。还剩表格里的最后一项——小部件能以可预期的方式打开外部链接。为此我们需要 openExternal

稍后,当你开始修改代码时,正常的开发循环通常如下:

  1. 修改 JSX。
  2. 保存。
  3. 要么刷新 ChatGPT 的标签页,要么(有时)只需“轻触”一下小部件——比如发送一条新消息或再次启动 App(具体取决于你的模板和缓存设置)。

如果看不到变更,首先考虑三个嫌疑:开发服务器未启动、隧道掉线,或 ChatGPT 连到了旧的 URL。在“如果出错,去哪儿排查”一节我们会更详细地剖析这个场景。

5. 为什么不能直接放一个 <a href> 就完事

为了完成冒烟测试中的最后一项——点击按钮打开外部页面——我们需要搞清楚 openExternal。一个合乎直觉的问题是:“为什么一定要用 openExternal?直接放链接不行吗?”

问题在于,你的小部件并不是“直接跑在浏览器里”,而是运行在 ChatGPT 管控的 iframe 里。这个 iframe 处在相当严格的沙箱中:可能会有 Content Security Policy 限制、sandbox 属性、以及 target="_blank" 与弹窗拦截的种种怪异行为。结果就是,在这样的 iframe 中,<ahref="…">window.open() 的行为可能变得不可预测:从完全无效到弹出你无法控制的警告不等。

此外,从 UX 的角度,OpenAI 希望对你何时以及如何打开外部页面进行统一管控。因此,Apps SDK 提供了统一的桥接对象 window.openai:你的代码不直接去操作父窗口,而是通过清晰定义的 API 把动作委托给宿主应用。

6. API window.openai.openExternal:是什么、如何工作

在小部件的沙箱中,可以访问全局对象 window.openai。这是你的 UI 与 ChatGPT 之间的主要“桥梁”:你可以通过它调用工具、发送后续消息、切换显示模式、管理小部件状态,当然也包括打开外部链接。

在本讲里,我们只关心其中的一个方法:

window.openai.openExternal({ href: string }): void;

当你调用 window.openai.openExternal({ href: 'https://example.com' }) 时,ChatGPT 会:

  1. 检查该 URL 是否符合策略。
  2. 可能向用户显示一个警告(例如提示这是外部站点)。
  3. 在用户的浏览器中以新标签/窗口方式打开该链接。

需要把两件事想清楚。

首先,这是纯客户端操作。它不会调用 MCP 工具,不会访问你的后端,也不会消耗 OpenAI 的 Token。它只是向宿主应用发出一个信号:“请打开这个 URL”。

其次,这种方式与沙箱兼容。ChatGPT 会自行决定如何打开链接,避免让你的 iframe 过度使用 window.open()

7. 给我们的小部件加一个使用 openExternal 的按钮

现在我们来从“Hello GiftGenius”里打开一个外部链接。最简单的场景:一个“打开演示链接”的按钮,指向例如你的文档或服务落地页。

先写一个小小的 helper,这样 TypeScript 不会报错,而且当你直接在浏览器里打开 /widget(此时还没有 window.openai)时,小部件也不会崩:

// app/widget/openExternalSafe.ts
export function openExternalSafe(href: string) {
  if (typeof window !== 'undefined' && (window as any).openai?.openExternal) {
    (window as any).openai.openExternal({ href });
  } else {
    // 在没有 ChatGPT 的本地预览场景下的回退
    window.open(href, '_blank', 'noopener,noreferrer');
  }
}

这里我刻意使用了 (window as any),以免现在就去为 window.openai 做完整的类型声明。课程稍后会更规范地描述该对象的接口。目前只需确保代码能编译并运行即可。

现在把 helper 引入我们的小部件,并加上按钮:

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

import { openExternalSafe } from './openExternalSafe';

export default function GiftGeniusWidget() {
  return (
    <main style={{ padding: 16, fontFamily: 'system-ui, sans-serif' }}>
      <h1 style={{ fontSize: 24, marginBottom: 8 }}>
        Hello from GiftGenius
      </h1>
      <p style={{ marginBottom: 16 }}>
        这是你的第一个 ChatGPT App。接下来我们会教它挑选礼物。
      </p>
      <button
        type="button"
        onClick={() => openExternalSafe('https://example.com')}
        style={{
          padding: '8px 16px',
          borderRadius: 8,
          border: '1px solid #ccc',
          cursor: 'pointer',
        }}
      >
        打开演示链接
      </button>
    </main>
  );
}

点击时会发生什么。

如果小部件运行在 ChatGPT 内部,window.openai.openExternal 存在,ChatGPT 会按照其规则打开 https://example.com

如果你在普通浏览器中打开了 http://localhost:3000/widget,此时没有 window.openai,就会触发回退:用浏览器的常规方式打开新标签。在这里,window.open 只在直接在普通浏览器打开 /widget 时使用,也就是不在 ChatGPT 的沙箱内。在该场景下它会像往常一样工作,不会造成问题。

关于 openExternal 的细节,我们会在模块 3(关于小部件与沙箱的专题)中深入讲解,所以现在可以放心进入应用的运行环节。

8. 端到端的迷你冒烟测试

现在可以做一次完整的“实战演练”。按顺序走完这些步骤:

  1. 确认开发服务器已启动(npm run dev),并能在 http://localhost:3000/widget 看到 Hello from GiftGenius
  2. 确认到 3000 端口的隧道已建立,并且公网 URL 能在外部浏览器打开。
  3. 打开 ChatGPT,启用 Dev Mode,并确认你的 App 连接的是正确的 URL(公网地址,而不是 localhost)。
  4. 打开聊天,选择该 App(或让模型启动它)。
  5. 确认在内嵌小部件里能看到“Hello from GiftGenius”。
  6. 点击“打开演示链接”按钮,确认浏览器打开了 https://example.com(或你的地址)。

如果这些都顺利完成,说明:

  • 小部件的 HTML/JS 已由 Next 服务器正确构建并返回。
  • HTTPS 隧道能正确代理请求。
  • ChatGPT 信任你的 URL,并能加载小部件。
  • window.openai 工作正常,能够把“打开外部链接”的指令传达出去。

这正是我们对第一个冒烟测试的期望。

9. 如果出错,去哪儿排查

与“常规”前端不同,这里主要有三个诊断入口。要快速判断究竟是哪一处出问题了:

  1. 先看 ChatGPT 中的 UI。如果你看到的不是小部件,而是类似“Error loading app”或“We had trouble talking to your app”的报错,问题大概率在隧道或你的开发服务器可达性上。试着在浏览器中直接打开公网 URL:如果打不开,或打开的是 Next.js 错误页面,优先修这个。
  2. 然后在 ChatGPT 所在的浏览器页面打开 DevTools。那里有一个专门用于你的小部件的 iframe,在里面可以看到熟悉的 Console 标签。如果点击使用 openExternal 的按钮没有反应,看看是否有“window.openai is undefined”之类的错误。如果有——很可能是你并不是在 ChatGPT 中测试(而是直接用隧道 URL 打开的),或者忘了在文件里写 'use client';
  3. 同时关注运行 npm run dev 的终端。如果那里有构建错误(TypeScript、ESLint、编译),ChatGPT 最多只能看到旧版本代码,最坏情况下什么也看不到。如果没有错误却看不到更新,确认隧道仍然活着:不少隧道服务会在空闲超时后自动断开会话。

还有一个常见情况:在 localhost 下都正常,但通过隧道访问时得到 404 或奇怪的页面。这时请仔细检查基础路径(/widget/ 是否搞混)、basePath/assetPrefix 设置(如果你改过的话),以及 Dev Mode 中配置的地址。

10. 一点“收尾”建议:如何停掉相关进程

这虽是小事,但在实践中非常有用。新手经常忘记,开发服务器和隧道都是独立进程,会在后台持续运行。

如果你突然遇到“端口 3000 已被占用”,可能是某个终端里还藏着旧的 npm run dev。在 Windows 上有时需要去任务管理器里“跳舞”,在 macOS 与 Linux 上通常是在启动该进程的终端里用 Ctrl + C 结束它。

隧道也一样:如果你连续尝试了多个隧道,或忘了关掉旧的,就很容易搞不清 Dev Mode 里你的 App 现在指向哪个 URL。建议养成习惯:准备结束会话时——先断开隧道、再停掉开发服务器,下次启动从“干净”的状态开始。

11. 第一次冒烟测试的常见错误

错误 №1:使用 localhost 而不是公网 HTTPS URL。
很常见:你在 Dev Mode 里不小心填了 http://localhost:3000,或者干脆忘了开隧道。在你的机器上当然没问题,但运行在云端的 ChatGPT 根本无法访问 localhost。解决办法很简单:检查 App 设置里填的是公网隧道的 HTTPS 地址,并且路径正确(/mcp 或根路径,取决于模板)。

错误 №2:忘记在小部件文件里加 'use client';。
你写了漂亮的 React 代码、加上了 onClick,又访问了 window.openai,但 Next.js 静悄悄地把页面当作服务端组件。最好的结果是得到“window is not defined”的报错,最坏的则是组件根本没法构建。想要使用浏览器 API,小部件必须是客户端组件,这由第一行的 'use client'; 说明。

错误 №3:直接调用 window.open() 而不是 openExternal。
看起来直接写 window.open('https://example.com') 更省事。在普通浏览器里也许还能用,但在 ChatGPT 的沙箱里行为就不可预测了:从完全无效到被拦截都有可能。对 ChatGPT Apps 来说,正确的做法是使用 window.openai.openExternal({ href }),把打开链接的动作交给宿主并遵循所有安全策略。

错误 №4:TypeScript 抱怨 window.openai,开发者通过“关掉类型检查”来“修复”。
有时大家会在文件开头写 // @ts-nocheck。这能消除编译错误,但也会把整个文件的 TypeScript 都关掉。更安全的做法要么在 window 周围按点使用 as any,要么在单独文件中为 window.openai 描述一个最小接口。本模块里我们选择了用带 (window as any) 的小 helper openExternalSafe,更严谨的类型稍后再加。

错误 №5:只在 localhost 下看结果,却不在 ChatGPT 内部验证。
只看到 http://localhost:3000/widget 能打开就以为大功告成,这很诱人。但本模块的意义正是要在 ChatGPT 内看到 App。普通浏览器里一切正常,并不保证 ChatGPT 能正确创建 iframe、通过隧道获取资源、也不会撞上 CORS/CSP。一个完整的冒烟测试必须包含在 ChatGPT 界面里启动 App 的步骤。

错误 №6:忘记了隧道,或隧道掉线了。
你更新了代码,但 ChatGPT 里不是旧版本就是完全加载失败。很多时候是隧道因超时被关闭,但开发者模式仍然指向旧 URL。如果在普通浏览器里打开隧道 URL 就报错——先把隧道恢复,再去怀疑 Apps SDK。

错误 №7:忽略 iframe 里的控制台。
做 SPA 的开发者习惯在自己应用的 DevTools 看 console.log,但在 ChatGPT 里这是一个 iframe,需要在 DevTools 里选择正确的 Frame。如果只看顶层,你可能一条错误都看不到,但小部件里其实早就“红了”。养成“在小部件的 iframe 上打开 DevTools”的习惯,会大幅节省精力。

1
调查/小测验
第一个 ChatGPT 应用第 2 级,课程 4
不可用
第一个 ChatGPT 应用
第一个 ChatGPT 应用:模板、Dev Mode、隧道
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION