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[] としての完全な会話。それぞれに roleparts 配列が含まれます
  • 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>
  );
};

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

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

「考え中…」の状態

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

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

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

ThinkingMessageShimmer コンポーネントを使用します。これは、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 つのデモが完成しました。