CodeGym /课程 /ChatGPT Apps /与服务器交互:window.fetch 与 openExternal

与服务器交互:window.fetch 与 openExternal

ChatGPT Apps
第 3 级 , 课程 4
可用

1. 两条出路:导航与数据

如果一个普通的 Next.js 开发者听到“要访问服务器”,手往往会本能地去拿 fetch 或最顺手的 HTTP 客户端。在 ChatGPT Apps 的世界里,这种反射动作会带来痛苦。

在本课程的小部件安全部分,我们建议从一开始就打破旧习惯。小部件不生活在开放互联网中:它处于强隔离之中,网络访问会被宿主的策略过滤和限制。

小部件对外只有三扇基础“窗口”:

  1. 导航:把用户带到外部世界。使用 openExternal
  2. 数据交换:获取/发送 JSON,与后端“对话”。可以通过 fetch,但可能受到严格限制。
  3. MCP tool call:调用工具(MCP/后端),不受上述前端侧的限制。

本讲我们聚焦于第一条也是最安全的路径(导航),并谨慎地认识受控的 fetch。后续模块将把 MCP 与工具作为与服务器进行“正经沟通”的主要方式来展开。

2. openExternal:安全的用户“传送门”

为什么不能直接用 window.open

在普通 Web 应用中,你可能会这样写:


window.open("https://example.com", "_blank");

在 ChatGPT 的沙盒中,这要么不起作用,要么表现得很奇怪。小部件是一个带有严格 sandbox 的隔离 iframe,它并不具有与浏览器标签页相同的权限。

此外,ChatGPT 宿主希望控制你在何时、带用户去往何处,以便:

  • 防止隐性追踪;
  • 向用户展示清晰的确认 UI(尤其在移动端/桌面客户端);
  • 确保在不同环境(Web、桌面、移动 App)中链接行为一致。

因此提供了专门的 API openExternal,可通过 window.openai 或更方便的 React 钩子 useOpenExternal 使用。

useOpenExternal 长什么样

在官方 Apps SDK 示例中,useOpenExternal 大致实现如下:


export function useOpenExternal() {
  const openExternal = useCallback((href: string) => {
    if (typeof window === "undefined") return;

    if (window?.openai?.openExternal) {
      try {
        window.openai.openExternal({ href });
        return;
      } catch (error) {
        console.warn("openExternal failed, falling back to window.open", error);
      }
    }

    window.open(href, "_blank", "noopener,noreferrer");
  }, []);

  return openExternal;
}

核心思路很简单。我们首先尝试使用 ChatGPT 的原生机制 (window.openai.openExternal)。 如果小部件并非在 ChatGPT 中渲染(例如开发时直接在浏览器里打开),就优雅地回退到普通的 window.open

在你的应用中,如果使用了 OpenAI 的标准模板,这个钩子已包含其中,应该按这种方式使用——而不是直接去操作 window.openai

示例:GiftGenius 中的“在商店查看”按钮

假设我们的 GiftGenius 的 toolOutput 返回的推荐里带有 productUrl 字段。给每张卡片加一个按钮,在你的网站上打开该商品:

import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{
    recommendations: { id: string; title: string; price: string; url: string }[];
  }>();
  const openExternal = useOpenExternal();

  if (!toolOutput) return <p>暂时没有推荐…</p>;

  return (
    <div>
      {toolOutput.recommendations.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">{gift.price}</div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            打开
          </button>
        </div>
      ))}
    </div>
  );
}

对用户而言:他点击按钮,ChatGPT 可能会显示一个系统弹窗“打开外部网站?”然后在新标签页或默认浏览器中打开你的页面。你不会携带任何密钥、token 等,只是把人“从聊天带到网站”。

3. 沙盒中的 window.fetch:这不是你习以为常的 fetch

前端通常的预期

通常的逻辑是:“既然是浏览器,那就能随便请求任何配好了 CORS 的 URL。最差也就是报错,但总可以试试。”

在 ChatGPT Apps 生态里,这是一个危险的误解。小部件周围的沙盒并非“鸡蛋里挑骨头”,而是基本的安全要求:防止小部件跟踪用户、访问任意域名、扫描本地网络,乃至表现得像“浏览器中的迷你浏览器”。

相关安全文档同样强调,在 Apps SDK 中小部件的任意网络访问要么不存在,要么受到强限制——这不是 bug,而是有意的架构决定。

实践中是什么样

在典型的 ChatGPT 环境中:

  • fetch 可能可用,但仅限一小部分域(通常是运行你 App 的域,及少数明确允许的 API);
  • 请求可能通过宿主的专用代理转发,并对请求头与 URL 做过滤;
  • 某些方法(如 PUTDELETE)或非标准请求头可能被安全策略拦截。

同时还有一条便利路径:如果你的小部件与后端同域(例如 Next.js 模板中 MCP 服务器与 UI 由同一个应用提供),内部请求 fetch("/api/...") 通常会被允许。

要点是——不要指望小部件可以访问互联网上的任意 API。与外部服务(Stripe、Notion、CRM 等)的“重”交互应放在 MCP/后端,由 ChatGPT 作为受信方去调用。

Insight

在 ChatGPT 小部件中,应立刻忘记相对路径并使用绝对 URL。原因很简单:你的 HTML 并不与后端处于同一域。ChatGPT 读取你的 html,将其放到自己的宿主上,并在隔离的 iframe 中渲染。任何 "/api/...""/static/logo.png" 都会突然相对于 ChatGPT 的域来解析,而不是你的应用域——然后一切都崩掉。

<base> 几乎救不了场。实测发现,如果小部件未设置 widgetCSP,你可以写 <base href="https://my-app.dev/">: 资源会从你的域加载,但脚本依然会被沙盒规则限制。而且这只在 Dev Mode 下有效。

一旦你设置了正常的 openai/widgetCSP上线审核时无论如何都要设置),平台会重置 <base>,游戏就结束了:资源与脚本只能从 CSP 允许的域按绝对链接加载。

建议:在 ChatGPT 小部件中,所有对外的东西—— fetch、图片、CSS、供 openExternal 打开的页面—— 都应基于你通过配置/环境变量控制的应用基础域来构造完整 URL,而不是依赖相对路径或 <base>

4. 架构:瘦 UI,厚后端

fetch 的限制与整体沙盒出发,可以得到一个对全课程都很重要的架构原则。我们已经说过几次这句“口头禅”,现在是巩固的时候了:小部件就是一个“瘦 UI 层”。它渲染后端(通过 MCP/tools)已经准备好的内容,对用户操作做出反馈,极限情况下做少量小而安全的公共请求。

所有与鉴权、个人数据访问、密钥以及复杂业务逻辑相关的内容,都应该在服务器端。课程的安全文档强调:前端(React 小部件)是“public place”,零信任区域,密钥不应存在其中。

我对这一主题的研究给出的目标很明确:为 ChatGPT Apps 的“胖客户端”理念“钉上最后一颗钉子”。小部件只是“头部”,而“身体与大脑”在 MCP/后端。

所以:

  • openExternal——用于把用户导航到你的“正常”网站,那里可以运行熟悉的 SPA、个人中心等;
  • callTool(下一模块)——把任务交给模型,由你的后端执行的主要方式;
  • 来自小部件的 fetch——少量用于辅助的、安全的、最好是公共的请求,并且只请求你自己的应用。

5. 实践:在我们的 GiftGenius 中使用 openExternal

让我们更谨慎地把 openExternal 融入示例 App,同时想一想 UX。

迷你 UX 准则

当你把用户带到外部时,最好:

  • 明确提示他将去往何处;
  • 不要在没有说明的情况下“突然跳转”(要么由 GPT 说明“我将打开商店的网站……”,要么在按钮上写清楚)。

标题与按钮文案示例:

<button onClick={() => openExternal(gift.url)}>
  在商家网站打开
</button>

用户会明白:马上就会从温暖的聊天界面跳转到真实世界的购物车与支付页。

对列表组件的小重构

我们之前已经写过一个简单的 GiftListWidget。假设在前面的课里你已经实现了通过 toolOutput 展示礼物列表。现在我们做个更工整的版本:增加类型 Gift(含 url 字段)以及 openExternal 按钮。

type Gift = {
  id: string;
  title: string;
  priceLabel: string;
  url: string;
};

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
  const openExternal = useOpenExternal();

  if (!toolOutput || toolOutput.gifts.length === 0) {
    return <p>暂时没有找到任何内容。请尝试修改请求。</p>;
  }

  return (
    <div>
      {toolOutput.gifts.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">
              {gift.priceLabel}
            </div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            查看
          </button>
        </div>
      ))}
    </div>
  );
}

我们依然不直接操作 window.openai,而是使用方便的钩子——在没有 ChatGPT 环境时它会自动回退到 window.open。这里的 Gift 结构只是示例——在你的 App 里应与后端契合。

6. 实践:对我们后端的谨慎 fetch

现在来说说 fetch。再次提醒:复杂或敏感操作最好通过工具/MCP 完成。但有时你可能只想从自己的服务器拉一点轻量、公开的数据,例如热门礼物分类列表。

Next.js 中的简单公共 API 路由

在我们的 Next.js 项目中增加如下处理器:

// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";

const tags = ["给儿童", "给旅行者", "给玩家"];

export async function GET() {
  return NextResponse.json({ tags });
}

这个路由不关心用户,不需要 token,也不访问外部服务——只是返回一个静态数组。这样的代码几乎可以无风险地用于生产和沙盒。

在小部件中通过 fetch 调用该路由

现在在小部件组件中加载这些标签。考虑沙盒限制,最稳妥的做法是请求绝对 URL:也就是你的 App 所在的那个域——你通过隧道暴露并在 ChatGPT 的 Dev Mode 中注册的域(我们在 Dev Mode 与隧道模块里已经配置过)。

重要:你的小部件域会类似 https://genius.web-sandbox.oaiusercontent.com,因此不要使用相对路径来加载数据,只能用绝对路径。示例:

import { useEffect, useState } from "react";

export function PopularTags() {
  const [tags, setTags] = useState<string[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function loadTags() {
      try {
        const res = await fetch("https://giftgenius.app/api/public/popular-tags");
        if (!res.ok) throw new Error("Bad status");
        const data: { tags: string[] } = await res.json();
        if (!cancelled) setTags(data.tags);
      } catch (e) {
        if (!cancelled) setError("无法加载热门分类");
      }
    }

    loadTags();
    return () => {
      cancelled = true;
    };
  }, []);

  if (error) return <p>{error}</p>;
  if (!tags) return <p>正在加载热门分类…</p>;

  return (
    <div className="flex flex-wrap gap-2 text-sm">
      {tags.map((tag) => (
        <span key={tag} className="rounded border px-2 py-1">
          {tag}
        </span>
      ))}
    </div>
  );
}

要点在于:

  • 我们认真处理错误,并向用户展示清晰的信息;
  • 不要以为 fetch“一定能成功”——一旦更换域名或发起“奇怪请求”,沙盒策略随时可能阻断访问;
  • 不要在这里携带任何 token/密钥;如果需要认证——交给 MCP 与认证模块去做。

7. openExternal vs fetch vs 工具(callTool):各司其职

为了不混淆,建议记住这样一张“小职责矩阵”:

场景 该用什么 为什么这样做
打开落地页/商品/用户中心 openExternal 宿主可控的显式用户跳转
从 App 获取公共数据 fetch("my.com/api/...") 轻量 JSON,同一域名,无密钥
获取用户数据、数据库数据 callTool/MCP 需要鉴权、业务逻辑、安全后端
访问外部 API(Stripe…) MCP/服务器 前端不接触密钥,遵守策略

本模块的重点是学会有意识地选择工具。应当从“这是前端,所以都用 fetch 搞定”的思路,转向“这是受控的 UI 层,覆盖在 LLM+MCP 后端之上”的架构。

Insight

在 ChatGPT App 中与服务器的交互可以合理地分成两层:

  • ChatGPT ↔ MCP 服务器:模型调用 MCP 工具。每一次 tool-call 都是在启动或切换一个业务场景(礼物推荐、下单、费用计算等)。这里放置“重”逻辑、数据操作、外部 API 与鉴权。
  • 小部件 ↔ 服务器:小部件对自己的后端发起轻量 fetch() 请求,和/或在已激活的场景中通过 callTool() 触发相同的 MCP 工具。这些是局部步骤:补充数据、更新 UI 片段、确认状态。

也就是说,MCP 工具 = 启动/管理业务流程,而来自小部件的 fetch()/callTool() 是在既定场景内进行的小操作,不会改变对话的整体“剧情”。

8. 小练习

为了巩固,可以在 GiftGenius 里做一个小功能。

建议流程:

  1. 在礼物列表中添加“前往结算”按钮,通过 openExternal 打开你开发站点的结算页。
  2. 在礼物列表上方渲染上面示例中的 PopularTags,展示热门分类。加载失败时给出后备文本,不要让整个小部件崩掉。
  3. 注意 UX:在 GPT 回复或小部件 UI 中向用户解释“点击按钮后,我会在新标签页打开商店页面”。

这个小功能同时覆盖两条通道:

  • openExternal 用于显式导航;
  • fetch 用于与你的 App 并置的一个小型公共 API。

9. 使用 window.fetch 与 openExternal 时的常见错误

错误 1:把小部件当作面向你所有 API 的全功能 SPA 客户端。
旧习惯会驱使你“直接在 React 里调我们的 REST/GraphQL 就好了”。在 ChatGPT Apps 中,这会与沙盒正面冲突:部分请求根本发不出去,部分会被策略拦截,项目安全也会受影响。复杂逻辑与用户数据访问必须走 MCP/工具,而不是直接从小部件发起。

错误 2:在小部件代码里保存密钥与 token。
有时为了“快速原型”,会把某个服务的 API key 写进前端代码(“我只是测试一下”)。这对普通 SPA 都是坏主意,对 ChatGPT Apps 则是明确的否。小部件是公共环境;密钥应放在服务器配置或机密管理系统(如 Vercel env、KMS 等)中。

错误 3:认为对任何域的 fetch 都会“直接成功”。
即便在 Dev Mode 某个请求偶然成功了(例如隧道配置比较“宽松”),在生产环境也几乎肯定会失效:ChatGPT 会限制出站请求,小部件无法访问任意外部域。应假定小部件只能可靠地访问自己的域和一个非常小的白名单资源。

错误 4:用 window.open 替代 openExternal。
从技术上讲,有时 window.open 会成功,尤其在浏览器预览里,给人一种“一切正常”的错觉。但在真实 ChatGPT 环境,尤其是原生客户端中,行为不可预测。用户可能看不到跳转,或遇到奇怪的错误。正确方式是使用 openExternal(通过 useOpenExternal 钩子),它知道如何在当前环境中正确打开链接。

错误 5:不处理 fetch 的错误,也不向用户展示加载状态。
在沙盒中,网络错误不是例外而是常态:隧道可能掉线、域名可能变化、策略可能切断某些请求。如果你只是 await fetch(...),然后假设数据存在就直接渲染 UI——结果就是一个“时好时坏”的半残界面。务必加上 try/catch,检查 res.ok,展示“加载中……”与友好的错误信息。

错误 6:把 openExternal 变成隐藏重定向。
有时会想在点击任意按钮后立刻把用户带到外部网站,尤其是结账页,却在文字上不给任何上下文。这对用户和商店审核者都显得不友好。好的做法是明确写出将要发生的事:要么让 GPT 文本说明“我将打开商店页面……”,要么按钮文案足够透明(例如“前往商家网站支付”)。

错误 7:忘了小部件并不是对话的唯一“主导者”。
如果你的 UI 试图通过堆满自己的链接和网络请求来主导复杂流程,而忽视聊天与后续追问,结果会是更差的 UX 与模型效果。记住架构:GPT 决定何时展示 App、如何使用其结果,而小部件只负责提示与可视化。导航与网络调用都应当融入整体对话,而不是喧宾夺主。

1
调查/小测验
小部件(Apps SDK)第 3 级,课程 4
不可用
小部件(Apps SDK)
小部件(Apps SDK):状态、UI 与沙盒
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION