CodeGym /课程 /ChatGPT Apps /外观管理: displayMode、 <...

外观管理: displayModemaxHeightbordersthemelayout

ChatGPT Apps
第 3 级 , 课程 1
可用

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%”,并附有“取消”按钮。

简要对比

下表帮助直观理解:

模式 所在位置 典型场景 限制
inline
消息流中 列表、卡片、一两枚按钮 高度受限、宽度较窄
fullscreen
聊天之上 / 侧边 向导、复杂表单、表格 需要合理的 layout 和导航
PiP / 模态 浮层 状态、迷你表单、视频 空间极小,元素需大且简单

重要的是,不要把 fullscreen 当作“真正的应用”,把 inline 当作“预览”。它们是同一个 App,只是不同的“姿态”。

3. 处理模式的钩子:useDisplayModeuseRequestDisplayModeuseRequestModal

现在我们了解了从 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 中堆满 ifdisplayMode === ...)。把视图拆开更容易理解:

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:用户需要猜到底滚哪一层才能滚到目标按钮。

正确做法:

  1. 读取 maxHeight 限制。
  2. 构建 layout 时让主要滚动保留在聊天层(尤其是 inline)。
  3. 在 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)、颜色与边框:让小部件看起来“原生”

如果说 displayModemaxHeight 决定了我们有“多少空间”,那么主题(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-backgroundborder-bordertext-foregroundbg-primary 等是与 ChatGPT 主题绑定的 CSS 变量/工具类。推荐做法就是使用与主题挂钩的变量和类,而不是硬编码颜色。

6. Layout 与自适应:desktop、mobile、PiP

第三个维度是宽度与设备。简单化来看,小部件的外观由模式(displayMode)、可用高度(maxHeight)与可用宽度(desktop/mobile/PiP)共同决定。

本节聚焦第三个参数。桌面端 inline 小部件有一种宽度,移动端是另一种;PiP 则更狭小。Apps SDK 会传递 userAgentsafeArea 等信号,有时还会传容器尺寸,可通过 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 中展示最多三张礼物卡片;
  • 若礼物超过三个,显示提示“展开小部件以查看全部”;
  • 通过 useAutoResizenotifyIntrinsicHeight() 灵活调节高度。

同时样式应依赖于主题:使用与 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 有变更时踩坑。钩子(useDisplayModeuseMaxHeightuseOpenAiGlobaluseRequestDisplayMode)就是为隐藏这些繁琐而生的,代码也更干净。尽量使用它们,你会更省心。

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