公開日: 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 にフォールバックします。リストで説明する、コード スニペットを作成する、マークダウン形式を使用するなどの指示を試してみてください。回答は、書式設定されたコードブロック、表、インライン コードを使用してすぐにレンダリングされます。

まとめ
この 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 つのデモが完成しました。
- Vercel AI SDK デモで組み込みの Prompt API を使用する: テキスト生成、ストリーミング、構造化された出力を並べて表示
- Prompt API デモで Vercel AI SDK UI と AI 要素を使用する: Markdown レンダリングとダークモードを備えた完全なストリーミング チャットボット