公開日: 2026 年 7 月 16 日
Vercel AI
SDK で組み込みの Prompt API を使用するでは、generateText()、streamText()、ハイブリッド コード、
Output.object() を使用した構造化出力という 4 つのコア生成
プリミティブについて説明しました。これらはすべて @browser-ai/core を基盤としています。今回は、よりインタラクティブなものを作成します。ブラウザで完全に実行されるフル ストリーミング チャット UI です。Prompt API が使用できない場合は、クラウドモデルに自動的にフォールバックします。
作成する内容
次の処理を行う React チャット インターフェース。
- Vercel AI SDK の
useChatフックを使用して、マルチターンの会話をストリーミングします。 - バックエンド サーバーを必要とせずに、ブラウザ でモデルループを実行します。
- Prompt API が使用できない場合は、Gemini 2.5 Flash に自動的にフォールバックします。
- アシスタントの返信を GitHub フレーバーのマークダウンとしてレンダリングし、ストリーミング中に不完全なトークンを処理します。
- 最初のトークンを待機している間、「思考中…」のきらめきを表示します。
- 新しいメッセージに自動スクロールします。上にスクロールした場合は、一番下までスクロールするボタンが表示されます。
追加の依存関係
ai、@browser-ai/core、@ai-sdk/google に加えて、チャット UI には
React、AI SDK の React バインディング、いくつかのマークダウン パッケージが必要です。
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
投票構成: マルチエントリ ビルド
プロジェクトにはすでに 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 要素です。
これらは、shadcn スタイルのソース コンポーネントであり、npm からインストールするのではなく、
プロジェクトにコピーします。Response は、GitHub フレーバーのマークダウン(テーブル、タスクリスト、取り消し線)用の react-markdown
と、AI 出力のリンクと画像をサニタイズする remark-gfm で harden-react-markdown をラップします。
メッセージがまだストリーミングされている間、parseIncompleteMarkdown プロパティは true です。ストリーミング中に、モデルが **bold を書き込んでも
まだ ** を閉じない場合があり、リテラル
アスタリスクとしてレンダリングされるぶら下がりトークンが残ります。parseIncompleteMarkdown は、開いている **、__、`、~~ を閉じ、ぶら下がり [ リンクの開始を切り捨てます。これにより、レンダリングされた出力は増分チャンクごとにクリーンな状態になります。
「思考中…」の状態
メッセージの送信から最初のトークンの受信までの間、status は
'submitted' です。この期間中、アプリはアニメーションのきらめきを表示します。
{status === 'submitted' && messages.at(-1)?.role !== 'assistant' && (
<ThinkingMessage />
)}
条件 messages.at(-1)?.role !== 'assistant' は、アシスタント メッセージのストリーミングが開始された後にきらめきが
再表示されないようにします。
ThinkingMessage は Shimmer コンポーネントを使用します。これは、移動するグラデーションを持つ <span> で、background-clip: text を使用して「思考中…」のテキストにスイープ ハイライト効果を与えます。
自動スクロール
新しいコンテンツが到着すると、アプリは一番下までスクロールしますが、ユーザーがすでに一番下にいる場合に限ります。会話中に読んでいるものからスクロールすると、煩わしくなります。
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 の場合は、フローティングの一番下までスクロールするボタンが表示され、ユーザーが一番下に戻るとフェードアウトします。
入力エリア
入力時にテキストエリアの高さが 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、マークダウン レスポンスをクリーンにレンダリングする AI 要素コンポーネントです。Prompt API が使用できない場合は、自動的にクラウドにフォールバックします。
その結果、バックエンドを必要とせずにブラウザで完全に動作する 2 つのデモが作成されました。
- Vercel AI SDK デモで組み込みの Prompt API を使用する: テキスト生成、ストリーミング、構造化出力を並べて表示
- Prompt API デモで Vercel AI SDK UI と AI 要素を使用する: マークダウン レンダリングとダークモードを備えたフル ストリーミング チャットボット