Prompt API で Vercel AI SDK UI と AI 要素を使用する

公開日: 2026 年 7 月 16 日

Vercel AI SDK で組み込みの Prompt API を使用するでは、@browser-ai/core を利用した 4 つのコア生成プリミティブ(generateText()、streamText()、ハイブリッド コード、Output.object() を使用した構造化出力)について説明しました。今回は、よりインタラクティブなもの、つまり、ブラウザで完全に実行されるフル ストリーミング チャット UI を構築します。この UI は、Prompt API が使用できない場合にクラウドモデルに自動的にフォールバックします。

作成するアプリの概要

次の処理を行う React チャット インターフェース。

  • Vercel AI SDK の useChat フックを使用して、マルチターンの会話をストリーミングします。
  • バックエンド サーバーを必要とせずに、ブラウザでモデルループを実行します。
  • Prompt API が使用できない場合、Gemini 2.5 Flash に自動的にフォールバックします。
  • アシスタントの返信を GitHub フレーバーの Markdown としてレンダリングし、ストリーミング中に不完全なトークンを処理します。
  • 最初のトークンを待っている間、「考え中…」という光沢が表示されます。
  • 新しいメッセージに自動スクロールし、上にスクロールした場合は一番下までスクロールするボタンが表示されます。

追加の依存関係

ai、@browser-ai/core、@ai-sdk/google に加えて、チャット UI には React、AI SDK の React バインディング、いくつかの Markdown パッケージが必要です。

npm install react react-dom @ai-sdk/react
npm install react-markdown remark-gfm harden-react-markdown
npm install -D @types/react @types/react-dom

UI の場合は、Tailwind CSS、いくつかのコンポーネント ユーティリティ、アイコン用の Lucide も追加します。

npm install -D tailwindcss postcss autoprefixer
npm install clsx tailwind-merge lucide-react

Vite 構成: マルチエントリ ビルド

プロジェクトにはすでに index.html があります。2 つ目のエントリ ポイントとして chat.html を追加し、両方をビルドするように Vite を構成します。

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': resolve(__dirname, './src') } },
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html'),
        chat: resolve(__dirname, 'chat.html'),
      },
    },
  },
});

chat.html は最小限で、<div id="root"> とスクリプトタグのみです。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Built-in AI Chatbot</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/chat.tsx"></script>
  </body>
</html>

モデルの自動選択

チャットボットでは、アプリが自動的に選択を行います。まず組み込みモデルを試し、Prompt API が使用できない場合はクラウドにフォールバックします。

これは、React がマウントされる前のモジュールの読み込み時に行われるため、ユーザーが最初のメッセージを入力するまでにエージェントの準備が整います。

const agentPromise: Promise<ToolLoopAgent> = (async () => {
  const builtIn = browserAI();
  let model: any = builtIn;

  if (typeof builtIn.availability === 'function') {
    const availability = await builtIn.availability();
    if (availability === 'unavailable') {
      const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
      model = createGoogleGenerativeAI({ apiKey })('gemini-2.5-flash');
    } else if (availability === 'downloadable') {
      await builtIn.createSessionWithProgress(() => {});
    }
  }

  return new ToolLoopAgent({ model, instructions: 'You are a helpful assistant.' });
})();

新しい部分は ToolLoopAgent です。これは、任意のモデル上でマルチターンの会話ループを管理する AI SDK 抽象化です。モデルとシステム プロンプトを受け取り、内部でやり取りを処理します。

エージェントを useChat に接続する

@ai-sdk/react パッケージの useChat フックは通常、HTTP エンドポイントと通信します。ブラウザ側の推論には、代わりに DirectChatTransport を使用します。ToolLoopAgent ループは、サーバーを介さずにブラウザ内で完全に実行されます。

const transport = useMemo(() => new DirectChatTransport({ agent }), [agent]);
const { messages, sendMessage, status, stop } = useChat({ transport });

DirectChatTransport は会話の状態を保持するため、安定した参照である必要があるため、useMemo は重要です。レンダリングごとに再作成すると、会話がリセットされます。

useChat には次の機能があります。

  • Messages: UIMessage[] としての完全な会話。それぞれに role と parts 配列が含まれます
  • sendMessage({ text }): 新しいユーザー ターンを送信し、レスポンスのストリーミングを開始します。
  • Status: 'idle' | 'submitted' | 'streaming' | 'error'
  • Stop: 進行中の生成をキャンセルします

メッセージをレンダリングする

各メッセージには parts 配列があります。このチャットボットでは、type: 'text' 部分のみを考慮します。ユーザーのメッセージは右揃えの吹き出しで表示され、アシスタントのメッセージは左揃えでアイコン付きで表示されます。

const ChatMessage = ({ message, isStreaming }: { message: UIMessage; isStreaming: boolean }) => {
  const isUser = message.role === 'user';

  const textParts = message.parts.map((part, i) => {
    if (part.type !== 'text') return null;
    if (isUser) return <span key={i}>{part.text}</span>;
    return <Response key={i} parseIncompleteMarkdown={isStreaming}>{part.text}</Response>;
  });

  if (isUser) {
    return (
      <div className="flex flex-col items-end gap-2 animate-fade-up">
        <MessageContent className="w-fit max-w-[min(80%,56ch)] ...">
          {textParts}
        </MessageContent>
      </div>
    );
  }

  return (
    <div className="flex items-start gap-3">
      <AIIcon />
      <MessageContent className="text-[13px] leading-[1.65]">{textParts}</MessageContent>
    </div>
  );
};

MessageContent と Response は AI 要素です。これらは、npm からインストールするのではなく、プロジェクトにコピーする shadcn スタイルのソース コンポーネントです。Response は、GitHub フレーバーのマークダウン(表、タスクリスト、取り消し線)用の remark-gfm と、AI 出力のリンクと画像をサニタイズするための harden-react-markdown で react-markdown をラップします。

メッセージがストリーミングされている間、parseIncompleteMarkdown プロパティは true です。ストリーミング中に、モデルは **bold を書き込むことがありますが、** をまだ閉じないため、リテラル アスタリスクとしてレンダリングされるぶら下がりトークンが残ります。parseIncompleteMarkdown は開いている **、__、`、~~ を閉じ、ぶら下がっている [ リンクの開始を切り詰めて、レンダリングされた出力がすべての増分チャンクでクリーンな状態を保つようにします。

「考え中…」の状態

メッセージの送信から最初のトークンの受信までの間、status は 'submitted' です。この期間中、アプリにはアニメーションの光彩が表示されます。

{status === 'submitted' && messages.at(-1)?.role !== 'assistant' && (
  <ThinkingMessage />
)}

条件 messages.at(-1)?.role !== 'assistant' により、アシスタント メッセージのストリーミングが開始された後に、シマーが再表示されるのを防ぎます。

ThinkingMessage は Shimmer コンポーネントを使用します。これは、background-clip: text を使用してグラデーションを移動させる <span> で、「Thinking…」というテキストにハイライトが流れるような効果を与えます。

自動スクロール

新しいコンテンツが届くと、ユーザーがすでに一番下にいる場合にのみ、アプリは一番下までスクロールします。会話中に読んでいるものをスクロールして消してしまうと、ユーザーは不快に感じるでしょう。

const [isAtBottom, setIsAtBottom] = useState(true);

useEffect(() => {
  if (isAtBottom) endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, status, isAtBottom]);

const handleScroll = () => {
  const el = containerRef.current;
  if (!el) return;
  setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 50);
};

isAtBottom が false の場合は、下部にスクロールするフローティング ボタンが表示され、ユーザーが下部に戻るとフェードアウトします。

入力エリア

textarea は、入力イベントごとに高さを auto にリセットしてから scrollHeight に設定することで、入力に応じて自動的にサイズ変更されます。Enter キー(Shift+Enter ではない)で送信されます。レスポンスのストリーミング中は、送信ボタンが stop() を呼び出す停止ボタンに置き換えられます。

<textarea
  onInput={(e) => {
    const el = e.currentTarget;
    el.style.height = 'auto';
    el.style.height = `${el.scrollHeight}px`;
  }}
  onKeyDown={(e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      if (input.trim() && !isStreaming) {
        sendMessage({ text: input });
        setInput('');
      }
    }
  }}
/>;
{
  isStreaming ? (
    <Button variant="outline" onClick={stop}>
      Stop
    </Button>
  ) : (
    <Button type="submit" disabled={!input.trim()}>
      Send
    </Button>
  );
}

読み込み状態でのマウント

agentPromise は非同期であるため、Chat をレンダリングする前に待機します。App ラッパーは Promise を解決し、その間にスピナーを表示します。

function App() {
  const [agent, setAgent] = (useState < ToolLoopAgent) | (null > null);

  useEffect(() => {
    agentPromise.then(setAgent);
  }, []);

  if (!agent) {
    return (
      <div className="flex h-dvh items-center justify-center">
        <Loader size={20} />
      </div>
    );
  }

  return <Chat agent={agent} />;
}

エージェントが解決すると(組み込みモデルがすぐに起動するか、モデルのダウンロードを待機するかにかかわらず)、スピナーが消え、チャット UI がマウントされます。

デモ

ライブデモは、ブラウザで完全に動作するフル機能のチャットボットです。メッセージを入力して Enter キーを押します。Prompt API が利用可能な場合、ネットワーク リクエストなしで、オンデバイス モデルから直接返信がストリーミングされます。ブラウザが Prompt API をサポートしていない場合、自動的に Gemini 2.5 Flash にフォールバックします。リストで説明する、コード スニペットを作成する、マークダウン形式を使用するなどの指示を試してみてください。回答は、書式設定されたコードブロック、表、インライン コードを使用してすぐにレンダリングされます。

AI アシスタントとの会話を示すチャット インターフェース。テキスト入力フィールドとストリーミング応答領域が表示されている。

まとめ

この 2 つの記事では、Vercel AI SDK がブラウザの組み込み Prompt API で実現できることの全範囲、つまり、生の生成プリミティブから洗練されたストリーミング チャット インターフェースまでを見てきました。

Vercel AI SDK で組み込みの Prompt API を使用するでは、ストリーミング テキスト生成と非ストリーミング テキスト生成に generateText() と streamText() を使用する方法、Output.object() で構造化された JSON 出力をリクエストする方法、生成ロジックを変更せずに実行時に組み込みモデルとクラウド プロバイダを選択するハイブリッド コードを記述する方法について説明しました。

このドキュメントでは、同じビルディング ブロックを使用して、完全な React UI を作成しました。会話ループを管理する ToolLoopAgent、ブラウザでレスポンスを直接ストリーミングする DirectChatTransport を含む useChat、Markdown レスポンスを到着時にクリーンにレンダリングする AI Elements コンポーネント。これらはすべて、Prompt API が利用できない場合に自動的にクラウドにフォールバックします。

その結果、バックエンドを必要とせず、完全にブラウザ上で動作する 2 つのデモが完成しました。