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 角度,把流程拆成若干逻辑步骤更合理。例如:
- 收礼人是谁,以及送礼场景。
- 预算与礼物类型(实物、体验、数字)。
- 检查并确认选择。
在代码里,可以用一个简单的分步状态机来表达:
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。
基于文档和早期使用者的经验,有几条重要特性需要了解:
- PiP 的空间非常有限。它不是用来放表单和复杂布局的,而是适合两三项关键指标和一两枚按钮。
- 在桌面端,PiP 会“吸顶”,滚动时保持可见;但在移动端,它常常会自动变成 fullscreen。
- 使用 requestDisplayMode 请求 mode 为 "pip" 并不保证得到真正的 PiP。平台可能返回其他模式(例如 fullscreen),甚至在旧版 SDK 上出现奇怪行为,因此务必检查 Promise 的结果并准备好回退方案。
由此得到一个简单的 UX 结论:PiP 里只放最重要的东西。计时器、物流进度、任务状态、“展开”按钮。不要塞 12 个复选框、10 列的表格,或“顺便帮我煮杯咖啡”。
8. GiftGenius + PiP:耗时搜索与后台进度
回到 GiftGenius。设想一个场景:用户完成了 fullscreen 向导并点击“确认”,你的后端开始执行一段重的挑选任务——可能通过 MCP 服务器调用多个外部 API、重算价格、应用诸多筛选。这可能会花 10–20 秒。
从 UX 角度,不希望让用户在 fullscreen 中盯着转圈 20 秒。更好的做法是:
- 启动挑选任务;
- 把界面缩到 PiP,显示进度;
- 让用户可以继续聊天(例如询问补充问题);
- 完成后——把结果以 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 实现与版本,对开发者而言并无保证。
务实做法:
- 把所有关键状态(向导步骤、用户输入、后台任务 ID)保存在:
- 后端(通过 MCP 服务器与会话 token),
- 或 ChatGPT 上下文(例如通过返回“当前 workflow 状态”的工具),
- 或 URL 参数/本地存储(在安全前提下)。
- 把 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 导航是否可用、按钮是否有清晰的文字标签。
GO TO FULL VERSION