0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAI Apps SDK:**Custom UX(カスタム UI)**を作る ― `window.openai` 徹底解説と実装レシピ

Posted at

ChatGPT 内に**自分のUI(React など)**を組み込み、会話の流れを壊さずにリッチな体験を提供するためのガイド。
この記事では window.openai APIMCP サーバ のつなぎこみ、状態管理表示モード切替サンプルコードを通して、最小構成から実用構成まで一気に理解します。
出典:Build a custom UX / Reference / 公式 Examples repo。 ([OpenAI Developers][1])


0. 何ができるの?(概要)

  • UIはサンドボックス化された iframe 内で実行され、ChatGPT ホストとは window.openai でやり取りします(データ受け渡し、ツール呼び出し、表示モード切替、外部リンクなど)。([OpenAI Developers][1])
  • コンポーネントは 会話にインライン で差し込まれ、必要に応じて fullscreen / PiP へ切替可能。モバイルでは PiP が fullscreen に強制される場合があります。([OpenAI Developers][1])
  • ツール結果は structuredContent / content / _meta の3系統で受け渡し。_metaモデルに見えず、コンポーネントだけが利用できます。([OpenAI Developers][2])

1. プロジェクトのひな型

app/
  server/                 # MCP サーバ(Node/Python どちらでも)
  web/                    # UI(React)をここでバンドル
    package.json
    tsconfig.json
    src/component.tsx
    dist/component.js     # ← esbuild で出力する単一バンドル
  • 依存は最小に、バンドルはできるだけ軽く(iframe なので体感に直結)。([OpenAI Developers][1])

セットアップ例(Node 18+)

cd app/web
npm init -y
npm i react@^18 react-dom@^18
npm i -D typescript esbuild

([OpenAI Developers][1])

esbuild スクリプト(package.json)

{
  "scripts": {
    "build": "esbuild src/component.tsx --bundle --format=esm --outfile=dist/component.js"
  }
}

([OpenAI Developers][1])


2. window.openai の型と基本API

window.openaiグローバル値操作API を提供します。主なものだけ抜粋:

  • グローバル値theme, locale, displayMode, maxHeight, safeArea, toolInput, toolOutput, toolResponseMetadata, widgetState など。

  • 操作API

    • callTool(name, args):MCP のツールを直接呼ぶ
    • sendFollowUpMessage({ prompt }):会話に追記(ユーザーが発言したかのように)
    • openExternal({ href }):外部リンクを開く
    • requestDisplayMode({ mode })inline | pip | fullscreen を要求(拒否される可能性あり/モバイルでは PiP→fullscreen)
    • setWidgetState(state)ウィジェット状態 を保存(モデルにも見える。4k tokens 目安 で小さく)
      ※ 変更通知は openai:set_globals カスタムイベントで飛んできます。([OpenAI Developers][1])

3. React フックで“会話とUI”を同期(実装)

3.1 useOpenAiGlobal:グローバル値を購読

// src/hooks/useOpenAiGlobal.ts
import { useSyncExternalStore } from "react";

export function useOpenAiGlobal<K extends keyof Window["openai"]>(key: K) {
  return useSyncExternalStore(
    (onChange) => {
      const handler = (e: any) => {
        const value = e?.detail?.globals?.[key];
        if (value !== undefined) onChange();
      };
      window.addEventListener("openai:set_globals" as any, handler, { passive: true });
      return () => window.removeEventListener("openai:set_globals" as any, handler);
    },
    () => window.openai[key]
  );
}

(公式の考え方を踏襲。変更時に UI が即時反映されます)([OpenAI Developers][1])

3.2 よく使うショートカット

export const useToolInput = () => useOpenAiGlobal("toolInput");
export const useToolOutput = () => useOpenAiGlobal("toolOutput");
export const useToolResponseMetadata = () => useOpenAiGlobal("toolResponseMetadata");
export const useDisplayMode = () => useOpenAiGlobal("displayMode");
export const useTheme = () => useOpenAiGlobal("theme");

([OpenAI Developers][1])

3.3 useWidgetState:ローカル state と永続 state を同期

// src/hooks/useWidgetState.ts
import { useCallback, useEffect, useState } from "react";
import { useOpenAiGlobal } from "./useOpenAiGlobal";

export function useWidgetState<T>(initial?: T | (() => T | null) | null) {
  const fromWindow = useOpenAiGlobal("widgetState") as T | null;
  const [state, setLocal] = useState<T | null>(() =>
    fromWindow ?? (typeof initial === "function" ? (initial as any)() : initial ?? null)
  );

  useEffect(() => { setLocal(fromWindow); }, [fromWindow]);

  const setState = useCallback((next: T | ((prev: T | null) => T | null)) => {
    setLocal((prev) => {
      const resolved = typeof next === "function" ? (next as any)(prev) : next;
      if (resolved != null) window.openai.setWidgetState(resolved as any);
      return resolved;
    });
  }, []);

  return [state, setState] as const;
}

setWidgetState はモデルにも共有されるため、機微情報は保存しないのがポイント)([OpenAI Developers][1])


4. 最小コンポーネント例(インライン→フルスクリーン、ツール更新、追記)

// src/component.tsx
import React, { useMemo } from "react";
import { createRoot } from "react-dom/client";
import { useToolInput, useToolOutput, useWidgetState, useDisplayMode } from "./hooks";

type Pizza = { id: string; name: string; city: string };

function PizzaListApp() {
  const input = useToolInput() as { city?: string };
  const output = useToolOutput() as { places?: Pizza[] } | null;
  const [favIds, setFavIds] = useWidgetState<string[]>(() => []);
  const display = useDisplayMode();

  const places = output?.places ?? [];

  const toggleFav = (id: string) =>
    setFavIds((prev) => {
      const set = new Set(prev ?? []);
      set.has(id) ? set.delete(id) : set.add(id);
      return Array.from(set);
    });

  const refresh = async () => {
    await window.openai.callTool("refresh_pizza_list", { city: input?.city ?? "Tokyo" });
  };

  const goFullscreen = async () => {
    await window.openai.requestDisplayMode({ mode: "fullscreen" }); // 拒否される可能性あり
  };

  const draftItinerary = async () => {
    await window.openai.sendFollowUpMessage({
      prompt: "お気に入りのピザ屋で食べ歩きプランを立てて。",
    });
  };

  return (
    <div style={{ padding: 12 }}>
      <header>
        <h3>Pizza in {input?.city ?? ""}</h3>
        <p>display: {display}</p>
        <button onClick={refresh}>更新</button>
        <button onClick={goFullscreen}>全画面へ</button>
        <button onClick={draftItinerary}>会話にプランを追記</button>
      </header>

      <ul>
        {places.map((p) => (
          <li key={p.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
            <span>{p.name} / {p.city}</span>
            <button onClick={() => toggleFav(p.id)}>
              {favIds?.includes(p.id) ? "" : ""}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

createRoot(document.getElementById("pizzaz-list-root")!).render(<PizzaListApp />);

上記は公式の Pizza 例の要旨を再構成したもの。toolOutput を表示の主ソースに、widgetState でお気に入りを永続callTool で再フェッチrequestDisplayMode でレイアウト交渉sendFollowUpMessage で会話に追記という、Custom UX の基本動線を一通り押さえています。([OpenAI Developers][1])

HTML テンプレ(サーバから配信する text/html

<!-- server 側で配信するテンプレのイメージ(後述の registerResource で埋め込む) -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /></head>
  <body>
    <div id="pizzaz-list-root"></div>
    <script type="module" src="/dist/component.js"></script>
  </body>
</html>

5. サーバ側:ツール記述(_meta と出力テンプレート)

ポイント

  • ツール結果は structuredContent / content / _meta を返せます。

    • structuredContentcontent会話ログに出る(モデルが読める)
    • _metaモデルに出ないコンポーネントへ渡る(ハイドレーション用など)
  • コンポーネントを表示するには _meta["openai/outputTemplate"]UI テンプレの URI を設定。

  • UI からツールを呼びたいときは _meta["openai/widgetAccessible"] = true を付ける(コンポーネント→ツール呼び出しの許可)。([OpenAI Developers][2])

Node 風の registerTool 例(概念コード)

server.registerTool(
  "refresh_pizza_list",
  {
    title: "Refresh Pizza List",
    description: "Fetch pizza places by city.",
    inputSchema: {
      type: "object",
      properties: { city: { type: "string" } },
      required: ["city"]
    },
    _meta: {
      "openai/outputTemplate": "ui://widget/pizza.html",
      "openai/widgetAccessible": true,                // ← コンポーネントからの callTool を許可
      "openai/toolInvocation/invoking": "Searching…", // 実行中の表示テキスト(短文)
      "openai/toolInvocation/invoked": "Results ready"
    }
  },
  async ({ city }) => {
    const places = await fetchPizzaPlaces(city);
    return {
      structuredContent: { places },                            // モデル & UI に提示
      content: [{ type: "text", text: `Found ${places.length} places in ${city}.` }],
      _meta: { allById: Object.fromEntries(places.map(p => [p.id, p])) } // UI 専用の豊富データ
    };
  }
);

Tool result の三要素の意味は公式 Reference が詳しいです。([OpenAI Developers][2])


6. コンポーネントリソースの登録(CSP・説明文など)

registerResource で UI テンプレ配信時の _meta を設定できます:

  • "openai/widgetDescription":コンポーネント読込時にモデルへ説明(余計なナレーションを減らす)
  • "openai/widgetPrefersBorder":カードに枠線を付けたい希望
  • "openai/widgetCSP"connect_domains / resource_domains の CSP スナップショット
  • "openai/widgetDomain":ホスティング用の専用サブドメイン(省略時は既定のサンドボックス)([OpenAI Developers][2])

server.registerResource("html", "ui://widget/pizza.html", {}, async () => ({
  contents: [{
    uri: "ui://widget/pizza.html",
    mimeType: "text/html",
    text: pizzaHtmlTemplate, // 上掲の HTML をここに
    _meta: {
      "openai/widgetDescription":
        "Renders an interactive pizza list with favorites and refresh.",
      "openai/widgetPrefersBorder": true,
      "openai/widgetCSP": {
        connect_domains: ["https://api.example.com"],
        resource_domains: ["https://persistent.oaistatic.com"]
      }
    }
  }]
}));

([OpenAI Developers][2])


7. ルーティング(React Router)とホスト連携ナビゲーション

Skybridge(iframe ランタイム)が iframe の history を ChatGPT UI と同期します。
React Router の通常 API を使えば OK。([OpenAI Developers][1])

import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom";

function AppRouter() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<List />} />
        <Route path="place/:placeId" element={<Detail />} />
      </Routes>
    </BrowserRouter>
  );
}

function List() {
  const navigate = useNavigate();
  return (
    <ul>
      <li onClick={() => navigate("place/abc123")}>Open detail</li>
    </ul>
  );
}

戻る/進むは ChatGPT 側の UI とも整合します)([OpenAI Developers][1])


8. 表示モード(レイアウト)を交渉する

await window.openai.requestDisplayMode({ mode: "fullscreen" });
// 付与された mode は結果で返る。拒否される可能性あり。
// モバイルでは PiP が fullscreen に強制されることがある。

([OpenAI Developers][1])


9. よくある落とし穴とベストプラクティス

  • setWidgetState はモデルにも見える:機微なユーザーデータを入れない。4k tokens 目安で軽量化。([OpenAI Developers][1])
  • UI→ツール呼び出しの許可を忘れない:ツール定義に _meta["openai/widgetAccessible"] = true。([OpenAI Developers][2])
  • 冪等なツールに:UI から繰り返し叩かれても安全に。戻り値は次ターンでモデルが推論しやすい構造化に。([OpenAI Developers][1])
  • 依存を絞って軽く:チャートやDnD等は必要最小限に。([OpenAI Developers][1])
  • Examples repo を丸ごと動かす:Pizza/Carousel/Map/Album/Video などの完成例が揃っている。まずは近いものをコピーしてデータ層だけ差し替えるのが最速。([OpenAI Developers][1])

10. 参考リンク

  • Build a custom UX(本記事のベース)([OpenAI Developers][1])
  • Apps SDK Reference_meta、structuredContent、widgetAccessible など)([OpenAI Developers][2])
  • Apps SDK Examples(GitHub)(Pizza などの完成例)([GitHub][3])

付録:Tool result の完全例(structuredContent / content / _meta

server.registerTool(
  "get_zoo_animals",
  {
    title: "get_zoo_animals",
    inputSchema: { type: "object", properties: { count: { type: "number" } } },
    _meta: { "openai/outputTemplate": "ui://widget/widget.html" }
  },
  async ({ count = 10 }) => {
    const animals = generateZooAnimals(count);
    return {
      structuredContent: { animals },                                   // モデル & UI
      content: [{ type: "text", text: `Here are ${animals.length} animals.` }], // 会話に見えるテキスト
      _meta: { allAnimalsById: Object.fromEntries(animals.map(a => [a.id, a])) } // UIのみに渡す
    };
  }
);

どれが会話ログに出るかどれが UI 専用かの区別が重要)([OpenAI Developers][2])


まとめ

  • window.openai を中心に、状態(widgetState)ツール呼び出し(callTool)会話追記(sendFollowUpMessage)表示モード交渉(requestDisplayMode) を把握すれば、ChatGPT らしいリッチ UI を安全に組み込める。([OpenAI Developers][1])
  • サーバ側は _meta["openai/outputTemplate"] で UI をひも付け、widgetAccessible で UI→ツール実行を許可。返却は structuredContent / content / _meta を正しく使い分ける。([OpenAI Developers][2])
  • まずは Examples repo を動かして、データ層だけ差し替えるのが最短距離。([GitHub][3])
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?