CodeGym /課程 /ChatGPT Apps /Fullscreen 與 PiP:精靈、複雜內容、影片 + 聊天

Fullscreen 與 PiP:精靈、複雜內容、影片 + 聊天

ChatGPT Apps
等級 8 , 課堂 2
開放

2. 既然有 inline,為什麼還需要 fullscreen?

在前一講關於 inline 的內容中我們已經約定:如果任務很短,能在 5–7 個物件或單一畫面內完成,inline 卡片就是理想做法。幾個禮物清單、少量篩選器、一兩個按鈕——這些都可以直接嵌在對話串中很好地運作。

但任何應用都會遇到「再多加一張卡片」也無法解決的時候:

  • 需要收集很多參數(收禮者的個人檔案、配送限制、付款方式);
  • 需要多步驟的精靈(wizard);
  • 有大型表格、圖表、地圖或長篇說明。

在這裡,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") {
  // 渲染我們的精靈(wizard)
} else {
  // 渲染精簡的 inline UI
}

SDK 也提供 window.openai.requestDisplayMode({ mode }) 與/或 useRequestDisplayMode,讓你向主機請求切換模式。該方法會回傳一個含有實際設定模式的 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 UI,而 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>
  );
};

到目前為止看起來很像「打開一個 modal」,但差別在於控制權不在你的 React,而是在 ChatGPT 主機應用;它可能會顯示標題、系統「返回」按鈕等等。

5. 設計 GiftGenius 的 fullscreen 精靈(wizard)

現在來設計禮物挑選的 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="他/她與你的關係?"
        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>
  );
};

在無障礙(a11y)方面請留意:

  • 在 fullscreen 中,顯著的「下一步」、「返回」、「取消」按鈕應易於點擊;
  • 文字對比需足夠;
  • 可以用 Tab 依序走訪所有互動元素。

若可行,請為非標準控制元件(如自訂分類切換器)加上 aria-label。雖然本課不是 WCAG 考試,但基本的 a11y 注意事項能幫你在上架審查時少踩坑。

總結來看,fullscreen 精靈能解決複雜的多步驟流程:提供填表空間、進度與錯誤顯示。但應用的生命不止於此——很多任務會在「背景」繼續。這時就輪到第二種模式——PiP,接著我們來談。

7. 在 ChatGPT 世界中的 PiP,以及它為何「比較難搞」

我們已經了解如何用 fullscreen 處理複雜情境。現在看看相反的案例——當重要流程已啟動,只需「掌控進度」時,就該讓 PiP 出場。

在網頁世界裡,「picture-in-picture」通常讓人聯想到浮在角落的影片。在 ChatGPT 中,PiP 是小型浮動視窗,它會在滾動聊天時依然可見,能顯示狀態、進度或精簡的 UI。

根據文件與早期採用者的經驗,有幾點要注意:

  1. PiP 的空間極小。它不是放表單與複雜版面的地方,而是只放兩三個最重要的指標與一兩顆按鈕。
  2. 在桌面端,PiP 會「吸附」在上方且在任何滾動下皆可見;但在行動端,它常常會自動變成 fullscreen。
  3. 使用 requestDisplayMode 並把 mode 設為 "pip",不代表一定能拿到真正的 PiP。平台可能回傳其他模式(例如 fullscreen),或者在舊版 SDK 上有奇怪表現;因此一定要檢查 Promise 的結果並準備好 fallback。

因此很直觀的 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 放到 context,然後透過 useLongGiftJobContext 來取用。這裡略過 Provider 與 createContext 細節;重點是工作狀態集中於一處,不同 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(); // 透過 context,如上所述

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

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

  return <InlineGiftPreview onExpand={/* 如前 */} />;
};

這種情境也很適合語音模式(在語音那一講會談):用語音啟動挑選,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") {
    // Fallback:例如顯示訊息,或把 UI 調整成 fullscreen 版
    console.log("PiP 不可用,改用模式:", result.mode);
  }
};

如此就不會發生你以為會出現小視窗,卻得到全螢幕 UI 的情況——而你的介面上還擺著只適用於 PiP 的按鈕,顯得很突兀。

最後,記得 maxHeight 與內部滾動:即便在 fullscreen,主機仍可能限制容器高度,你需要合理安排滾動,避免出現三層巢狀卷軸。

11. 使用 fullscreen 與 PiP 的常見錯誤

錯誤 1:把 fullscreen 當成預設模式。
有些開發者看到「fullscreen」就想把自己的 App 變成嵌在聊天裡的獨立 SPA。結果只要一提到禮物,使用者就被帶入全螢幕精靈,但他其實只想要幾個靈感。OpenAI 指南強烈建議先從 inline 開始,只有在客觀需要時才擴展到 fullscreen。

錯誤 2:把 PiP 當成縮小版 fullscreen。
PiP 的空間非常有限,卻有人硬塞所有東西:標籤、表單、篩選。使用者面對一個小到點不到的介面。正確做法是在 PiP 只顯示狀態和一兩顆關鍵按鈕(例如「展開」「取消」)。

錯誤 3:未解釋的模式切換。
當元件毫無提示地突然展開為 fullscreen(沒有 GPT 的文字說明或使用者的明確點擊)時,會讓人困惑。自動縮成 PiP 或回到 inline 也是如此。每一次切換都要搭配模型訊息簡短說明:「我現在會打開詳細精靈」在 fullscreen 前說;「我會把挑選縮到小視窗,讓它在背景進行」在 PiP 前說。

錯誤 4:忽略行動端與平台差異。
只在桌面端測試,PiP 表現看似正常;到了行動端卻全部變 fullscreen、版面跑掉,按鈕還超出 safe area。文件明確警告行動端的 PiP 可能以 fullscreen 實作,且行為會隨 SDK 版本變動,因此一定要在目標裝置測試,並謹慎使用 requestDisplayMode

錯誤 5:過度相信切換模式時狀態會被保存。
只依賴 React state 而沒有任何伺服器/持久化支援,會導致搞笑情況:使用者完成精靈的兩步,按了「縮到 PiP」,回來時卻回到第一步、欄位全空。最好假設切換模式時你的元件可能被卸載,然後用這個風險來設計狀態管理。

錯誤 6:忘了 fullscreen 精靈的無障礙。
漂亮的大畫面表單,對視力較弱或只用鍵盤的人不一定友善。過小的字體、低對比、難以分辨的「下一步」「返回」按鈕——這些常見問題不只影響 UX,也會讓你在上架審查時吃虧。至少檢查基本項目:文字對比、字級、Tab 導覽是否可用,以及按鈕是否有清楚的文字標示。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION