1. 为什么要管理外观
此刻你的小部件大概率看起来像一个“普通的 React 组件”:一个 div、一些列表、两三个按钮。在常规 Web 中这往往足够。但在 ChatGPT 里有一个细节:你的 UI 生活在聊天内部,用户已经有很多视觉上下文——消息、其他 Apps、语音界面,以及容器尺寸的限制。
需要记住两点。
首先,小部件有显示模式(displayMode):inline、fullscreen,有时是 PiP。模式决定了可用面积、滚动行为以及用户预期。
其次,平台会告诉小部件高度限制(maxHeight)和主题(theme)。如果忽略它们,在一条消息里画出一个 Notion 大小的东西,聊天会变成“黑洞”——一切都沉在一个巨大的 iframe 里。OpenAI 明确建议保持 UI 简洁,并尊重系统的配色/排版。
GiftGenius 的典型场景能很好地展示实际效果。用户说:“给朋友挑个不超过 $50 的礼物。”ChatGPT 启动 GiftGenius,它在 inline 模式展示紧凑的礼物卡片和几个按钮。用户点击“了解更多”——小部件请求 fullscreen,并在其中展示筛选、详细描述、评价。下单过程中,可以通过 PiP/模态显示一个“小状态窗”如“正在处理订单…”,而不是遮挡整个聊天。
本讲目标:
- 了解当前的 displayMode 并作出合理响应;
- 按需切换模式(inline ↔ fullscreen,有时是 PiP);
- 遵守 maxHeight,避免“双重滚动”;
- 根据明/暗主题和屏幕宽度适配样式;
- 构建在 ChatGPT 中看起来“原生”的 layout。
2. displayMode 模式:inline、fullscreen、PiP
先来理解概念。displayMode 是你的小部件在 ChatGPT 中的容器状态。它由平台提供(通过 window.openai.displayMode 或钩子 useDisplayMode),可能取值为 "inline"、"fullscreen"、"pip" 等。
Inline
Inline 是默认模式。小部件直接插入到消息流中,作为文本回复之间的一个“块”。宽度受到聊天栏宽度限制(桌面端约 700–800px,手机端即屏幕宽度),高度是动态的,但不是无限的。
Inline 非常适合:
- 短小、完整的呈现:礼物卡片、选项列表、搜索摘要;
- 一两个动作:“选择”“取消”“显示更多”。
对于 GiftGenius,这是主要模式:用户发来请求,你展示 3–5 张礼物卡片并提供按钮,而不是占满整个屏幕。
Fullscreen (Canvas)
Fullscreen(或 canvas)是你的小部件占据可视区域的大部分时的模式。聊天不会消失:输入框仍然可用,但主要注意力在你的 UI 上。
在以下情况启用 fullscreen 更合适:
- 输入项很多或是复杂向导(下单、复杂筛选、设置);
- 需要显示大表格、地图、对比几十个元素;
- inline 已经装不下,开始像 700px 高的迷你 Excel。
在 GiftGenius 中,fullscreen 用于提供完整的筛选、排序、详细描述,甚至多个标签页。
PiP / 模态
PiP(picture-in-picture)和模态是覆盖在主内容之上的小型“浮窗”。在当前的 Apps SDK 实现中,PiP 常被实现为一种特殊的 displayMode,或通过 requestModal() 打开的模态。
它们很有用,适用于:
- 显示长时间任务的状态(处理订单、渲染视频);
- 提出小问题而不打断主流程(快速确认);
- 让用户“把小部件置顶”,同时继续聊天。
在 GiftGenius 中,它可以是一个“小面板”:“正在下单… 30%”,并附有“取消”按钮。
简要对比
下表帮助直观理解:
| 模式 | 所在位置 | 典型场景 | 限制 |
|---|---|---|---|
|
消息流中 | 列表、卡片、一两枚按钮 | 高度受限、宽度较窄 |
|
聊天之上 / 侧边 | 向导、复杂表单、表格 | 需要合理的 layout 和导航 |
| PiP / 模态 | 浮层 | 状态、迷你表单、视频 | 空间极小,元素需大且简单 |
重要的是,不要把 fullscreen 当作“真正的应用”,把 inline 当作“预览”。它们是同一个 App,只是不同的“姿态”。
3. 处理模式的钩子:useDisplayMode、useRequestDisplayMode、useRequestModal
现在我们了解了从 UX 角度 inline/fullscreen/PiP 是什么,接着看看如何通过 Apps SDK 的钩子在代码中与它们交互。
我们不直接读取 window.openai.displayMode,而是使用模板中的钩子,它订阅变更并免去你处理 SDK 事件的繁琐。典型接口如下:
// 伪类型,具体名称以模板为准
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
function useDisplayMode() {
// 返回当前模式
return { displayMode: 'inline' as DisplayMode };
}
function useRequestDisplayMode() {
// 请求切换模式的函数
return {
requestDisplayMode: (mode: DisplayMode) => {
/* 调用 window.openai.requestDisplayMode */
},
};
}
让我们写一个简单组件,显示当前模式并提供“展开 / 收起”按钮:
import { useDisplayMode, useRequestDisplayMode } from '@/apps-sdk';
export function DisplayModeDebug() {
const { displayMode } = useDisplayMode();
const { requestDisplayMode } = useRequestDisplayMode();
const toggle = () => {
requestDisplayMode(displayMode === 'inline' ? 'fullscreen' : 'inline');
};
return (
<div className="text-xs text-gray-500 flex gap-2 items-center">
<span>模式:{displayMode}</span>
<button onClick={toggle} className="underline">
切换
</button>
</div>
);
}
在真实 App 中,你通常会隐藏这类“调试”元素,但在 Dev Mode 下,这种组件非常有助于感受切换时小部件的行为。
使用不同子组件区分 Inline 与 Fullscreen
常见误区是试图用同一个 layout 适配所有模式,并在 JSX 中堆满 if(displayMode === ...)。把视图拆开更容易理解:
import { useDisplayMode } from '@/apps-sdk';
import { GiftListInline } from './GiftListInline';
import { GiftListFullscreen } from './GiftListFullscreen';
export function GiftWidget() {
const { displayMode } = useDisplayMode();
if (displayMode === 'fullscreen') {
return <GiftListFullscreen />;
}
return <GiftListInline />;
}
这样代码的语义就是“如果是 fullscreen——这是复杂向导,否则——这是紧凑的 inline”。并且每个子组件都可以根据自身约束独立定制样式。这正是模块建议的做法:把不同模式拆成独立子组件,而不是在一个组件里用巨大的 if/else。
模态:useRequestModal
如果模板提供了 useRequestModal 钩子,它的接口通常类似:
const { requestModal } = useRequestModal();
// requestModal({ title }) 或类似用法。
模态在某些方面与 fullscreen 相似,但并不能替代它:fullscreen 用于大型场景,模态用于单个短步骤(确认操作、输入优惠码等)。
4. 尺寸控制:maxHeight、滚动与 notifyIntrinsicHeight()
第二个重要维度是高度。平台会告诉小部件:“这是可用的最大高度。”你可以从 window.openai.maxHeight 或钩子 useMaxHeight 中读取这个限制。
为什么不能直接设成“height: 5000px”
如果忽略 maxHeight 并设置巨大的固定高度,ChatGPT 会被迫裁切你的内容。或是出现双重滚动:外层是聊天的滚动,内层是你的小部件的滚动。这是糟糕的 UX:用户需要猜到底滚哪一层才能滚到目标按钮。
正确做法:
- 读取 maxHeight 限制。
- 构建 layout 时让主要滚动保留在聊天层(尤其是 inline)。
- 在 fullscreen 中可以适度允许内部滚动,但要谨慎。
useMaxHeight 与容器限高
我们来写一个简单的包裹组件,为根容器设置最大高度:
import { useMaxHeight } from '@/apps-sdk';
export function WidgetContainer(props: { children: React.ReactNode }) {
const { maxHeight } = useMaxHeight(); // 例如 600
return (
<div
style={{ maxHeight }}
className="overflow-y-auto p-4 bg-background border border-border rounded-xl"
>
{props.children}
</div>
);
}
这里我们如实限制了高度,并在容器内部开启垂直滚动,但保持在合理范围。实践中,在 inline 下应尽量避免大的内部滚动,遇到很长的列表可以只显示一部分并提供“显示更多”,或引导去 fullscreen。
动态高度与 notifyIntrinsicHeight()
还有一个细节:你的内容可能会随时间改变尺寸。例如起初展示“正在加载礼物…”的转圈,然后变成 10 张卡片的列表,随后用户又展开/收起筛选。为了让 ChatGPT 正确为小部件预留空间而不裁切,需要在高度变化时向宿主报告新值。为此可以使用 notifyIntrinsicHeight()。
模板中这通常被包成一个类似 useAutoResize 的钩子。它可以大致这样实现:
import { useEffect, useRef } from 'react';
import { useNotifyIntrinsicHeight } from '@/apps-sdk';
export function useAutoResize() {
const ref = useRef<HTMLDivElement | null>(null);
const { notifyIntrinsicHeight } = useNotifyIntrinsicHeight();
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
notifyIntrinsicHeight(entry.contentRect.height);
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [notifyIntrinsicHeight]);
return ref;
}
使用方式:
export function GiftListInline() {
const containerRef = useAutoResize();
return (
<div ref={containerRef}>
{/* 你的内容 */}
</div>
);
}
思路很简单:当根 div 的高度发生变化时,调用 SDK API,ChatGPT 会相应调整容器。这个“自动调整尺寸的包裹”是经验丰富的开发者直接推荐的模式。
小示意图
用流程图表示如下:
flowchart TD
A[小部件内容发生变化] --> B[ResizeObserver 捕获到新高度]
B --> C["调用 notifyIntrinsicHeight(newHeight)"]
C --> D[ChatGPT 增大/减小容器]
D --> E[用户看到没有裁切的顺滑滚动]
到这里,我们解决了尺寸与高度:小部件不应越界,更别把用户拖进“双重滚动”的迷宫。
5. 主题(theme)、颜色与边框:让小部件看起来“原生”
如果说 displayMode 与 maxHeight 决定了我们有“多少空间”,那么主题(theme)与配色则决定了这个界面在聊天中“如何呈现”。
ChatGPT 至少支持明暗两种主题。平台通过 window.openai.theme 和/或 _meta["openai/theme"] 将其传入你的小部件,在 React 模板中可以使用 useOpenAiGlobal("theme") 或 useTheme 之类的钩子读取。
核心观点:你的 UI 应该适配平台主题,而不是强行自带一套。
获取主题
一个简单钩子示例:
import { useOpenAiGlobal } from '@/apps-sdk';
export function useThemeMode() {
const theme = useOpenAiGlobal<'light' | 'dark'>('theme') ?? 'light';
return { theme };
}
组件中使用:
export function ThemedCard(props: { children: React.ReactNode }) {
const { theme } = useThemeMode();
const className =
theme === 'dark'
? 'bg-slate-900 text-slate-100 border-slate-700'
: 'bg-white text-slate-900 border-slate-200';
return (
<div className={`rounded-xl border p-4 ${className}`}>
{props.children}
</div>
);
}
在真实项目中,你很可能使用 Tailwind 并配置 darkMode: 'class',然后在小部件根容器上挂 dark 类。但本质不变:主题来自 Apps SDK,而不是你自己随意定义。
颜色、边框与排版
根据 OpenAI 的指南:
- 使用系统字体和得体的排版;
- 不要过度覆盖系统色彩;
- 小部件应是聊天的“原生”元素,而不是带着夸张渐变的独立着陆页。
GiftGenius 容器的一个不错的模式:
export function GiftCard(props: { title: string; price: string }) {
return (
<div className="rounded-xl border border-border bg-background p-3 flex flex-col gap-2">
<div className="font-medium text-foreground">{props.title}</div>
<div className="text-sm text-muted-foreground">{props.price}</div>
<button className="self-start px-3 py-1 text-sm rounded-full bg-primary text-primary-foreground">
选择
</button>
</div>
);
}
这里假定 bg-background、border-border、text-foreground、bg-primary 等是与 ChatGPT 主题绑定的 CSS 变量/工具类。推荐做法就是使用与主题挂钩的变量和类,而不是硬编码颜色。
6. Layout 与自适应:desktop、mobile、PiP
第三个维度是宽度与设备。简单化来看,小部件的外观由模式(displayMode)、可用高度(maxHeight)与可用宽度(desktop/mobile/PiP)共同决定。
本节聚焦第三个参数。桌面端 inline 小部件有一种宽度,移动端是另一种;PiP 则更狭小。Apps SDK 会传递 userAgent、safeArea 等信号,有时还会传容器尺寸,可通过 useOpenAiGlobal 读取。
通用原则
以下是几个重要原则。
首先,不要指望固定宽度。用户屏幕可能很窄(手机)也可能很宽(大屏桌面)。因此布局应更多使用 flex/grid 的 auto-fit,而不是死板的 width: 400px。
其次,避免水平滚动。如果表格或卡片放不下,最好切到 fullscreen 或展示精简版。当然也可以用轮播。
再次,注意 PiP/模态通常非常窄,不要在里面塞大表单——用户很难准确点中输入框。
这些点在文档中被反复强调:自适应、safeArea、桌面与移动的差异,以及过载布局的风险。
为 inline 与 fullscreen 提供不同的 layout
回到 GiftGenius。inline 与 fullscreen 的礼物列表外观可能差别很大。我们做两个组件。
紧凑的 inline:最多 3 张卡片;移动端单列,宽屏双列。
export function GiftListInline() {
const gifts = useGiftData(); // 示例钩子,从 toolOutput 获取
return (
<WidgetContainer>
<h2 className="text-base font-semibold mb-3">
礼物精选
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{gifts.slice(0, 3).map(gift => (
<GiftCard
key={gift.id}
title={gift.title}
price={`${gift.price} $`}
/>
))}
</div>
{gifts.length > 3 && (
<p className="mt-3 text-xs text-muted-foreground">
已显示前 3 个选项。展开小部件以查看全部。
</p>
)}
</WidgetContainer>
);
}
再看 fullscreen 版本:网格、筛选、更多卡片。
export function GiftListFullscreen() {
const gifts = useGiftData();
const [query, setQuery] = useState('');
const filtered = gifts.filter(g =>
g.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<div className="h-full flex flex-col gap-4 p-4">
<header className="flex gap-2 items-center">
<h1 className="text-lg font-semibold flex-1">
为你推荐的礼物
</h1>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="按名称筛选"
className="px-2 py-1 text-sm border rounded-md flex-1"
/>
</header>
<main className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(gift => (
<GiftCard
key={gift.id}
title={gift.title}
price={`${gift.price} $`}
/>
))}
</div>
</main>
</div>
);
}
这里我们允许 fullscreen 内容内部的垂直滚动(在 main 上使用 overflow-y-auto),这对全屏是合理的。而 inline 版本遵循指南,保持紧凑、可在“2 秒内读完”。
示意:按模式的行为
再用状态图巩固一下:
stateDiagram-v2
[*] --> Inline
Inline: 3 张卡片,最少文字
Inline --> Fullscreen: 点击 "展开" / "查看全部"
Fullscreen: 网格、筛选器、更多数据
Fullscreen --> Inline: "关闭" 按钮 / 宿主动作
Fullscreen --> PiP: 长耗时操作,显示进度
PiP: 小型状态面板
PiP --> Inline: 操作完成,显示最终消息
这个场景与常见的 UX 模式非常相似:inline 作为预告,fullscreen 作为工作台,PiP 作为过程指示器。
7. 实战:同一小部件的两种模式
该动手了。在本讲的练习中,我们对当前教学应用做两个步骤。
步骤 1. 带卡片的 Inline 小部件
扩展当前的 GiftGenius,使其在 inline 模式下:
- 显示标题“礼物精选”;
- 从 toolOutput 中展示最多三张礼物卡片;
- 若礼物超过三个,显示提示“展开小部件以查看全部”;
- 通过 useAutoResize 与 notifyIntrinsicHeight() 灵活调节高度。
同时样式应依赖于主题:使用与 theme 绑定的类或变量,而不是硬编码颜色。
步骤 2. 带表单的 Fullscreen 版本
然后添加 fullscreen 视图,它应当:
- 显示标题 + 按名称搜索;
- 在网格中展示所有礼物;
- 允许在主区域内垂直滚动;
- 提供“返回对话”按钮(调用 requestDisplayMode('inline'))。
组合方式可以如下:
export function GiftGeniusWidget() {
const { displayMode } = useDisplayMode();
return (
<>
<DisplayModeDebug />
{displayMode === 'fullscreen' ? (
<GiftListFullscreen />
) : (
<GiftListInline />
)}
</>
);
}
在 ChatGPT Dev Mode 中,你可以手动切换模式,或在 inline 版本中的“查看全部”按钮点击时(通过 useRequestDisplayMode)以编程方式请求 fullscreen。本练习将帮你巩固:同一个 App 会根据 displayMode 的不同呈现出截然不同的外观与行为。
8. 管理小部件外观的常见错误
在继续课程前,我们总结几条与 displayMode、尺寸、主题和 layout 相关的常见坑。尽早规避它们,能显著改善与 Apps SDK 的相处体验。
错误 1:忽略 displayMode,一股脑儿把一切做成接近 fullscreen。
有时开发者会画一个很重的 layout(几乎像独立 SPA),勉强塞进 inline。结果用户看到一个迷你 Notion,带滚动条和无数元素。正确做法是针对不同模式设计不同的视图,并尊重 inline 是紧凑、“一屏内”的格式。
错误 2:巨大固定高度与双重滚动。
设置 height: 800px 然后忘了 maxHeight,要么被裁切,要么同时出现内外两层滚动。用户要开始“寻找”正确的滚动条,这极其破坏体验。应改为读取 maxHeight,通过 max-height 进行限制,并在高度变化时通过 notifyIntrinsicHeight() 通知宿主。
错误 3:忽略主题,强行“全站品牌化”。
如果你强上自家字体、背景、强对比渐变,并完全无视 ChatGPT 的明/暗主题,就破坏了平台的一致性。指南明确建议:使用系统颜色与字体,品牌只做克制的点缀(按钮、图标、logo)。通过钩子跟踪 theme 并调节配色。
错误 4:在 PiP/模态中塞过于复杂的 UI。
把一个包含众多字段的表单塞进小小的 PiP 窗口是不可取的。那里只适合非常简单的场景:进度指示、一两个按钮、单个输入框。其余都应该进入 fullscreen。
错误 5:死板按 800px 刻板布局,不在移动端测试。
死盯 800px,指望“手机上总能挤一挤”。实际上,ChatGPT 的移动端客户端宽度与行为完全不同,PiP 更窄。别忘了 userAgent/safeArea,用无固定宽度的 grid/flex,并至少用窄布局看一眼你的小部件。
错误 6:绕过钩子直接操作 window.openai。
形式上你可以写 const mode = window.openai.displayMode,但之后你得自己订阅事件、考虑 React 更新,并在 SDK 有变更时踩坑。钩子(useDisplayMode、useMaxHeight、useOpenAiGlobal、useRequestDisplayMode)就是为隐藏这些繁琐而生的,代码也更干净。尽量使用它们,你会更省心。
GO TO FULL VERSION