CodeGym /课程 /ChatGPT Apps /Fullscreen 和 PiP:向导、复杂内容、视频 + 聊天

Fullscreen 和 PiP:向导、复杂内容、视频 + 聊天

ChatGPT Apps
第 8 级 , 课程 2
可用

2. 有了 inline,为什么还需要 fullscreen?

在上一讲关于 inline 的内容里我们已经约定:如果任务很短,能在 5–7 个对象或单屏内完成,inline 卡片就是理想方案。几个礼物条目、两三个筛选器、一两枚按钮——这些都可以很好地直接嵌在消息流里。

但任何应用都会遇到“再加一张卡片”也救不了的时刻:

  • 需要收集很多参数(收礼人信息、配送限制、支付方式);
  • 需要由多个步骤组成的向导;
  • 有大型表格、图表、地图、长描述。

此时 inline 就开始“捉襟见肘”:宽度受聊天栏限制,高度也有限,没有导航,而且聊天只有一条滚动条。正是为这类场景,Apps SDK 提供了 fullscreen 模式——一种“沉浸式”界面,组件可占据屏幕的大部分区域,呈现复杂布局。

今天的第二位主角是 PiP,一个悬浮在聊天之上的小窗口。它的典型角色是:后台任务状态、小型播放器、计时器、进度指示器。当某个耗时任务“在后台”进行,而用户继续与 GPT 对话时,PiP 非常理想。

要牢记:fullscreen 和 PiP 都不是 inline 的替代,而是增量能力。先从 inline 开始;当 inline 变得局促时再切到 fullscreen;当“有趣的事情”已启动、只需“随时看得到状态”时,就可以切到 PiP。

3. 技术基石:displayMode 与模式切换

从 Apps SDK 的角度看,你的组件有一个当前的显示状态——displayMode。在写作本课程时有三种主要模式:"inline""fullscreen""pip"(picture-in-picture)。

宿主(ChatGPT)通过 window.openai 中的全局数据以及 SDK 的专用 hook 向组件告知当前模式。在典型的 React 模板中,大概是这样的:

// 来自 Apps SDK 模板的别名
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'

if (mode === "fullscreen") {
  // 渲染我们的向导
} else {
  // 渲染精简的 inline 界面
}

SDK 还提供了 window.openai.requestDisplayMode({ mode }) 方法和/或 useRequestDisplayMode hook,用来请求宿主切换模式。该方法返回一个包含实际设置结果的 Promise,因为平台可能会拒绝或微调你的请求(比如在移动端,请求 PiP 几乎都会变成 fullscreen)。

模式生命周期可以示意为:

stateDiagram-v2
    [*] --> Inline
    Inline --> Fullscreen: requestDisplayMode('fullscreen')
    Fullscreen --> Inline: requestDisplayMode('inline') / 按钮 "返回"
    Fullscreen --> PiP: requestDisplayMode('pip')
    PiP --> Fullscreen: "展开"
    PiP --> Inline: 任务完成

真实的名称与模式集合可能会随 SDK 版本变化,因此在生产中请务必以文档为准,而不是“课程里是这么写的”。

4. 第一次切换:做一个“展开到全屏”的按钮

从小处着手:拿我们已有的 inline 小组件 GiftGenius——前面模块的教学 App,目前展示 3–5 张礼物卡片——给它加一个“打开详细挑选”的按钮,用于切换到 fullscreen。

假设我们的模板里有两个 hook:

import { useDisplayMode, useRequestDisplayMode } from "@/sdk/display";

export const GiftGeniusWidget: React.FC = () => {
  const mode = useDisplayMode();
  const requestDisplayMode = useRequestDisplayMode();

  if (mode === "fullscreen") {
    return <GiftFullscreenWizard />;
  }

  return (
    <InlineGiftPreview
      onExpand={async () => {
        await requestDisplayMode({ mode: "fullscreen" });
      }}
    />
  );
};

这里的 InlineGiftPreview 是现有的 inline 界面,而 GiftFullscreenWizard 是我们即将设计的新向导组件。在 onExpand 处理器中,我们不仅调用 requestDisplayMode,还等待其 Promise——这样后续可以对拒绝进行响应(例如,在 fullscreen 不可用时提示用户)。

InlineGiftPreview 本身很简单:

type InlineGiftPreviewProps = {
  onExpand: () => void;
};

const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
  return (
    <div>
      <h3>礼物筛选</h3>
      {/* ...礼物卡片... */}
      <button onClick={onExpand}>打开详细挑选</button>
    </div>
  );
};

这看起来很像“打开一个模态窗”,但区别在于控制权不在你的 React,而在 ChatGPT 宿主应用;它可能会展示标题、系统“返回”按钮等。

5. 设计 GiftGenius 的 fullscreen 向导

现在来设计礼物挑选的 fullscreen 向导。从 UX 角度,把流程拆成若干逻辑步骤更合理。例如:

  1. 收礼人是谁,以及送礼场景。
  2. 预算与礼物类型(实物、体验、数字)。
  3. 检查并确认选择。

在代码里,可以用一个简单的分步状态机来表达:

type WizardStep = "recipient" | "preferences" | "review";

type WizardState = {
  step: WizardStep;
  recipient?: { ageRange: string; relation: string };
  preferences?: { budget: number; categories: string[] };
};

创建 GiftFullscreenWizard 组件,在 React 中保存该状态并渲染相应的页面。

const GiftFullscreenWizard: React.FC = () => {
  const [state, setState] = useState<WizardState>({ step: "recipient" });

  const goNext = (partial: Partial<WizardState>) => {
    setState((prev) => ({ ...prev, ...partial }));
  };

  if (state.step === "recipient") {
    return <RecipientStep state={state} onNext={goNext} />;
  }

  if (state.step === "preferences") {
    return <PreferencesStep state={state} onNext={goNext} />;
  }

  return <ReviewStep state={state} />;
};

每个步骤都是带表单的小组件。比如第一步:

type StepProps = {
  state: WizardState;
  onNext: (partial: Partial<WizardState>) => void;
};

const RecipientStep: React.FC<StepProps> = ({ state, onNext }) => {
  const [relation, setRelation] = useState(state.recipient?.relation ?? "");
  const [ageRange, setAgeRange] = useState(state.recipient?.ageRange ?? "");

  return (
    <div>
      <h2>我们要给谁选礼物?</h2>
      <input
        placeholder="TA 和你的关系是什么?"
        value={relation}
        onChange={(e) => setRelation(e.target.value)}
      />
      <input
        placeholder="年龄(例如 25–34)"
        value={ageRange}
        onChange={(e) => setAgeRange(e.target.value)}
      />
      <button
        onClick={() =>
          onNext({
            recipient: { relation, ageRange },
            step: "preferences",
          })
        }
      >
        下一步
      </button>
    </div>
  );
};

第二步收集预算和类别,第三步调用 callTool / MCP 工具,根据这些参数进行挑选并展示结果。

在 fullscreen 界面中,我们有空间容纳:

  • 进度条或 stepper;
  • 更详细的表单字段与提示;
  • 错误状态(“出了点问题,请重试”)。

来自 UX 指南的建议:每个步骤尽量保持简单,不要堆满字段;3–4 个清晰步骤要优于一个“巨无霸”表单。

6. fullscreen 向导的 UX:进度、错误、返回

把表单铺满全屏只是完成了一半。用户还需要:

  • 知道自己所处的步骤;
  • 能够返回上一步;
  • 在耗时操作期间看到进展。

最简单的 stepper 可以纯视觉实现:

const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
  const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
  return <p>第 {index} 步,共 3 步</p>;
};

然后把 Stepper 插入每个页面即可。更高级的做法是渲染横向“阶梯”步骤,但在本课程里我们不展开排版细节。

关键点是错误处理。假设在最后一步我们调用 search_gifts 工具:

const ReviewStep: React.FC<StepProps> = ({ state }) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleConfirm = async () => {
    setLoading(true);
    setError(null);
    try {
      await callTool("search_gifts", {
        recipient: state.recipient,
        preferences: state.preferences,
      });
      // 结果稍后会出现在聊天/组件中
    } catch (e) {
      setError("未能挑选到礼物,请重试。");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {/* 展示参数汇总 */}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button disabled={loading} onClick={handleConfirm}>
        {loading ? "正在挑选…" : "确认并开始挑选"}
      </button>
    </div>
  );
};

从可访问性角度,需要确保:

  • 在 fullscreen 中,“下一步”“返回”“取消”等大按钮易于点击;
  • 文本具有足够的对比度;
  • 可通过 Tab 按顺序遍历所有交互元素。

如果有条件——建议为非常规控件(例如自定义的类别切换)补充 aria-label。虽然本课程不等同于 WCAG 考试,但对 a11y 的基本关注能帮你在 Store 审核时少踩坑。

最终,fullscreen 向导很好地解决了复杂的多步骤场景:为表单、进度和错误提供了空间。但应用的生命并未止步于此——很多任务会“在后台”继续。为此我们有第二种模式——PiP,下面来聊聊它。

7. ChatGPT 里的 PiP 是什么,为何它“娇气”

我们已经了解了如何用 fullscreen 处理复杂场景。现在来看另一个极端——当关键流程已启动,只需“盯着进度”即可。这时就轮到 PiP 出场了。

在 Web 世界里,“picture-in-picture”通常与视频悬浮于内容之上联系在一起。在 ChatGPT 中,PiP 是一个小型悬浮组件窗口,在聊天滚动时始终可见,能够展示状态、进度或简洁的 UI。

基于文档和早期使用者的经验,有几条重要特性需要了解:

  1. PiP 的空间非常有限。它不是用来放表单和复杂布局的,而是适合两三项关键指标和一两枚按钮。
  2. 在桌面端,PiP 会“吸顶”,滚动时保持可见;但在移动端,它常常会自动变成 fullscreen。
  3. 使用 requestDisplayMode 请求 mode"pip" 并不保证得到真正的 PiP。平台可能返回其他模式(例如 fullscreen),甚至在旧版 SDK 上出现奇怪行为,因此务必检查 Promise 的结果并准备好回退方案。

由此得到一个简单的 UX 结论:PiP 里只放最重要的东西。计时器、物流进度、任务状态、“展开”按钮。不要塞 12 个复选框、10 列的表格,或“顺便帮我煮杯咖啡”。

8. GiftGenius + PiP:耗时搜索与后台进度

回到 GiftGenius。设想一个场景:用户完成了 fullscreen 向导并点击“确认”,你的后端开始执行一段重的挑选任务——可能通过 MCP 服务器调用多个外部 API、重算价格、应用诸多筛选。这可能会花 10–20 秒。

从 UX 角度,不希望让用户在 fullscreen 中盯着转圈 20 秒。更好的做法是:

  1. 启动挑选任务;
  2. 把界面缩到 PiP,显示进度;
  3. 让用户可以继续聊天(例如询问补充问题);
  4. 完成后——把结果以 inline 呈现,或打开新的 fullscreen 展示礼物。

我们写一个简单的 hook,来管理这个行为:

const useLongGiftJob = () => {
  const [status, setStatus] = useState<"idle" | "running" | "done">("idle");
  const requestDisplayMode = useRequestDisplayMode();

  const startJob = async (payload: any) => {
    setStatus("running");
    const resultMode = await requestDisplayMode({ mode: "pip" });
    console.log("实际模式:", resultMode.mode);

    await callTool("run_gift_job", payload);
    setStatus("done");
    await requestDisplayMode({ mode: "inline" });
  };

  return { status, startJob };
};

现在在 ReviewStep 中,不再直接调用 callTool,而是使用该 hook:

const ReviewStep: React.FC<StepProps> = ({ state }) => {
  const { status, startJob } = useLongGiftJob();

  return (
    <div>
      {/* ...汇总... */}
      <button
        disabled={status === "running"}
        onClick={() => startJob(state)}
      >
        {status === "running" ? "正在挑选礼物…" : "启动挑选"}
      </button>
    </div>
  );
};

为了让后台任务的状态同时对 fullscreen 向导和 PiP 窗口可见,在真实代码中,建议把 useLongGiftJob 提升到上下文中,通过 useLongGiftJobContext 读取。上下文的实现细节(Provider、createContext)这里略过:要点是 job-state 存在一个地方,不同 UI 层仅订阅它。

再单独写一个用于 PiP 展示的组件:

const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
  return (
    <div>
      <p>GiftGenius 正在运行…</p>
      <p>状态:{status === "running" ? "进行中" : "已完成"}</p>
      <button
        onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
      >
        展开
      </button>
    </div>
  );
};

在顶层组件里,我们把渲染逻辑改为同时考虑 PiP:

const GiftGeniusWidget: React.FC = () => {
  const mode = useDisplayMode();
  const { status } = useLongGiftJobContext(); // 通过上下文,如上所述

  if (mode === "pip") {
    return <GiftPipView status={status} />;
  }

  if (mode === "fullscreen") {
    return <GiftFullscreenWizard />;
  }

  return <InlineGiftPreview onExpand={/* 和之前一样 */} />;
};

这种场景与语音模式(将在 voice 讲解)也很契合:用语音启动挑选,PiP 展示进度,下方的聊天则继续“各自前行”。

9. 视频 + 聊天:当 fullscreen 与 PiP 变成媒体播放器

历史上,PiP 最常与视频悬浮关联。因此我们单独讨论“video + chat”场景。这里并没有什么魔法:大多数情况下,你只是在 fullscreen 或 PiP 窗口里显示视频。OpenAI 文档也把媒体场景作为使用 fullscreen 与 PiP 的典型示例。

这对 GiftGenius 意味着什么?例如:

  • 展示礼物的宣传片;
  • 简短教程“如何把礼物包装得更漂亮”;
  • 若干商品的视频评测。

在 fullscreen 中可以渲染一个完整的 <video>,附上说明和推荐;在 PiP 中则只保留播放器本身,或加一个小标题。

一个最简单的封装组件:

const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
  src,
  title,
}) => (
  <div>
    <h3>{title}</h3>
    <video
      src={src}
      controls
      style={{ width: "100%", borderRadius: 8 }}
    />
  </div>
);

在 fullscreen 向导中,我们可以给用户一个“观看该礼物的视频评测”的步骤,然后把它缩到 PiP:

const WatchVideoStep: React.FC = () => {
  const requestDisplayMode = useRequestDisplayMode();

  return (
    <div>
      <GiftVideoPlayer src="/videos/gift-wrap.mp4" title="如何包装礼物" />
      <button
        onClick={() => requestDisplayMode({ mode: "pip" })}
      >
        把视频留在角落并返回聊天
      </button>
    </div>
  );
};

媒体场景的几点实践建议:

  • 不要开启带声音的自动播放——这是通用的 UX 反模式;
  • 注意字幕与键盘可控的暂停(空格、方向键);
  • 在 PiP 窗口里不要放所有配套文本,只保留视频即可。

10. 状态、组件重建与移动端特性

此时最让人头大的问题往往是:“如果我在 inline 与 fullscreen 之间切换,React 的状态会保留下来吗?”

简短回答:不要指望

从技术上看,这取决于 SDK 版本与宿主实现:有的情况下切换模式不会重建 iframe;在另一些情况下,组件会在切换时卸载并重新挂载。文档中特别强调,切换模式时上下文是否保留依赖具体 SDK 实现与版本,对开发者而言并无保证。

务实做法:

  1. 把所有关键状态(向导步骤、用户输入、后台任务 ID)保存在:
    • 后端(通过 MCP 服务器与会话 token),
    • 或 ChatGPT 上下文(例如通过返回“当前 workflow 状态”的工具),
    • 或 URL 参数/本地存储(在安全前提下)。
  2. 把 React state 当作 UI 缓存/胶合层对待,并随时准备在模式切换后清空——随后从更可靠的来源恢复。

第二个微妙点与 requestDisplayMode 的结果有关。如上所述,请求 mode"pip" 的调用返回结果可能是 "fullscreen",尤其在移动端,不支持真正的 PiP 或会自动全屏。

典型模板:

const requestDisplayMode = useRequestDisplayMode();

const openPipSafe = async () => {
  const result = await requestDisplayMode({ mode: "pip" });
  if (result.mode !== "pip") {
    // 回退:例如提示用户或把 UI 适配到 fullscreen
    console.log("PiP 不可用,当前运行模式:", result.mode);
  }
};

这样你就不会出现“以为是个小窗”,却得到一个全屏界面、还塞着“PiP 专用”按钮的尴尬局面。在这种模式下,这样的界面会很奇怪。

最后,记得处理 maxHeight 与内部滚动:即使在 fullscreen,宿主也可能限制容器高度;你的任务是合理组织滚动,避免出现三层嵌套滚动条。

11. 使用 fullscreen 与 PiP 时的常见错误

错误 1:把 fullscreen 当作默认模式。
一些开发者看到“fullscreen”就想把自己的 App 变成聊天内的独立 SPA。结果一提到礼物,用户就被立即送到全屏向导,而他本来只想看几条点子。OpenAI 的指南强烈建议从 inline 开始,只有在客观需要时才扩展到 fullscreen。

错误 2:把 PiP 当作缩小版 fullscreen。
PiP 的空间非常有限,但有时人们会往里塞满:标签、表单、筛选器。用户得到一个“针尖大小”的界面,根本点不中。正确做法是在 PiP 中只放状态和一两枚关键按钮(例如“展开”和“取消”)。

错误 3:未解释的模式切换。
当组件在没有 GPT 文本提示或用户显式点击的情况下突然展开为 fullscreen,会让人迷惑。自动缩到 PiP 或回到 inline 亦然。每次切换都应配上一句简短说明:在进入 fullscreen 前说“现在打开详细向导”,在进入 PiP 前说“把挑选缩到小窗,期间继续计算”等。

错误 4:忽视移动端与平台差异。
只在桌面端测试,PiP 表现“很正常”;结果在移动端一切都变成 fullscreen,布局错位,按钮跑到安全区域外。文档明确提醒:移动端的 PiP 可能实现为 fullscreen,而且不同 SDK 版本的行为也会变。因此必须在目标设备上测试,并谨慎处理 requestDisplayMode 的结果。

错误 5:过度相信模式切换时状态会被保留。
只依赖 React state、没有任何服务端/持久化支持,会导致尴尬:用户完成了两个步骤,点了“缩到 PiP”,回来后又回到第一步且字段清空。更好的做法是假定模式切换时组件可能被卸载,并据此设计状态管理。

错误 6:忘了 fullscreen 向导的可访问性。
大屏上的漂亮表单并不总是对低视力或仅用键盘的用户友好。过小的文字、低对比度、难以分辨的“下一步/返回”按钮,都是糟糕 UX 甚至 Store 审核问题的常见来源。至少检查基础点:文本对比度、字号、Tab 导航是否可用、按钮是否有清晰的文字标签。

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