← ブログ

実際のユーザーに耐える AI チャット UI を shadcn/ui と Vercel AI SDK で作る方法

ストリーミングする AI チャットは、デモは簡単でも出荷は難しい。停止と再試行からツールの承認、CJK 入力まで、shadcn/ui と AI SDK のチャットが扱うべき状態。

Preet Suthar · · 6分で読めます

  • AI
  • React
  • shadcn/ui
  • Next.js

デモは半日でできます。AI SDK の useChat、メッセージのリスト、textarea、そしてトークンが画面にストリーミングされます。チームの全員が感心します。

そして実際の人が使います。日本語を変換中に Enter を押して、単語の途中で送信してしまいます。返信を停止すると UI には何も表示されません。ストリームの途中でツール呼び出しが承認を求めても、そのための UI がありません。読み返そうと上にスクロールすると、トークンが届くたびに最下部へ引き戻されます。

この画面は望んだ以上の回数作ってきました。直近では HextaUI のブロックとしてです。これはチャット UI が完成と言える前に対応しておくべきことのチェックリストで、それぞれに shadcn/ui と AI SDK 固有の話を添えています。

文字列ではなくメッセージパーツから始める

AI SDK の useChat はパーツで構成されたメッセージを返します。テキスト、推論、状態付きのツール呼び出し、ソース、ファイルです。連結した文字列ではなく、パーツを描画してください。以下の機能はすべてこれに依存しています。

import { isToolUIPart } from "ai"

{message.parts.map((part, i) => {
  if (isToolUIPart(part)) return <ToolCall key={i} part={part} />
  switch (part.type) {
    case "text":
      return <Markdown key={i} text={part.text} />
    case "reasoning":
      return <Thinking key={i} text={part.text} />
    case "source-url":
      return <Source key={i} url={part.url} />
    default:
      return null
  }
})}

ツールパーツの型は tool-<name> なので、isToolUIPart が確実な判定方法です。

ブロックには SDK の import を入れないでください。パーツを props として受け取ります。そうすれば、出力を同じ形に変換するだけで、同じコンポーネントが LangGraph のバックエンドでも自前のサーバーでも動きます。

コンポーザー

バグの大半は textarea に住んでいます。

Enter で送信、Shift+Enter で改行。 誰もがこうします。しかし変換中の処理をしている人はほとんどいません。日本語、中国語、韓国語を入力しているときは IME が開いていて、Enter はメッセージではなく変換候補を確定します。event.nativeEvent.isComposing を確認して抜けてください:

function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
  if (event.key !== "Enter" || event.shiftKey) return
  if (event.nativeEvent.isComposing) return
  event.preventDefault()
  submit()
}

テキストに合わせて、上限つきで伸ばす。 最初は一行、八行程度まで伸び、その先はスクロールします。高さの変化はアニメーションさせますが、キー入力のたびに React の state を経由してはいけません。レイアウトエフェクトで scrollHeight を測り、DOM ノードに高さを直接設定します。ネイティブの input リスナー内の setState は、負荷がかかるといずれキー入力を落としたり遅らせたりします。

下書きはリロードしても残す。 下書きはチャット id をキーにして、デバウンスしつつ localStorage に保存します。うっかりリフレッシュして長いプロンプトを失った人は、二度と許してくれません。

返信中に入力されたメッセージはキューに入れる。 モデルがまだ書いている最中にユーザーが別のメッセージを送ったら、送信を無効にするか、キューに入れてストリーム終了後に送ります。黙って捨てるのは最悪の選択肢で、しかもそれがデフォルトです。

添付ファイルには進捗と再試行を表示する。 アップロードは失敗します。失敗は消えてしまうトーストではなく、添付ファイルのカード上に再試行ボタン付きで表示してください。

読みやすいストリーミング

モデルはトークンをまとめて吐き出します。届いたものをそのまま描画すると、テキストはカクつきます。何もなし、次に 12 語、また何もなし、という具合です。なめらかにしましょう。バッファが増えたら速くなる一定のペースで文字を表示すれば、ストリームから大きく遅れることも、カクつくこともありません。

ストリーミング中の Markdown には固有の罠があります。書きかけの構文です。モデルが **bold まで書いてまだ閉じていないと、レンダラーは一瞬アスタリスクをそのまま表示し、次に太字を表示します。未完了のインライン構文は、閉じるかストリームが終わるまで表示を保留してください。

コードブロックにも同じ配慮が必要です。ハイライトは差分で行い、トークンごとにブロック全体をハイライトし直してはいけません。

停止、再試行、編集、バージョン

停止したら途中までの返信を画面に残し、未完了だとわかるように「Stopped」のマーカーを見えるように付けます。削除してはいけません。

失敗した返信の再試行は、隠れたジェスチャーではなく、メッセージ上のボタンにします。regenerate() につなぎます。

以前のメッセージを編集するとブランチができます。古い返信と新しい返信の両方が存在します。メッセージ上に小さな 1 / 2 の切り替えを表示して、ユーザーがバージョンを行き来できるようにします。自前のチャット UI の多くはここで諦めますし、ChatGPT や Claude から来た人が最も恋しがる機能でもあります。

失敗した返信には理由と再試行を付けます。アクションのない「問題が発生しました」は行き止まりです。

スクロール

ユーザーが最下部にいるときは新しいトークンに追従します。上にスクロールした瞬間に追従をやめ、見逃した件数付きの「最新へ移動」ボタンを表示します。移動したら再び追従します。

上に古い履歴を読み込むときに、表示位置を動かしてはいけません。先頭に追加する前後でスクロール高さを測り、同じフレーム内でその差分だけ scrollTop を調整します。

ユーザーがメッセージを送ったら、そのメッセージがビューポートの上端に固定され、返信がその下にストリーミングされるようにスクロールします。二大チャットアプリはいまそう振る舞っていて、ユーザーもそれを期待しています。

ツール呼び出しと承認

AI SDK は各ツールパーツに状態を与えます。input-streaming、input-available、output-available、output-error、そして human-in-the-loop では approval-requested、approval-responded、output-denied です。すべての状態に見た目が必要です。

読み取りと検索は一行の要約に畳んで構いません。編集、コマンド、エラーは展開したままにします。承認はストリームをブロックするので、明確な Allow と Deny に加えて、「代わりにこうして」と伝える手段が必要です。そのテキストは拒否理由としてモデルに返します。

結果は役に立つときに見せます。ファイル編集は diff として開き、コマンドは出力付きのターミナルとして開き、検索は結果として開きます。「Done」としか言わないツール呼び出しは、ユーザーにツール呼び出しを無視することを教えます。

思考とソース

推論パーツは折りたためるようにし、回答が始まったらデフォルトで折りたたみます。モデルがまだ考えている間は、各ステップに追従するライブなラベル(「ドキュメントを検索中」「3 つのファイルを読んでいます」)のほうが、スピナーよりずっと安心感があります。

引用は、誰も読まない末尾のリストではなく、裏付ける主張のすぐ隣にインラインで置きます。

省略できないアクセシビリティ

  • 新しいメッセージは role="log" のリージョンを通して通知し、スクリーンリーダーのユーザーにも返信が聞こえるようにします。
  • 停止ボタンはキーボードで到達でき、ラベルを持ちます。
  • ストリーミング中のテキストが、ライブリージョンの読み上げを何百回も発火させてはいけません。メッセージの完了時か、文の区切りで読み上げます。
  • アイコンだけのボタンにはすべて aria-label を付けます。

まとめると

部品が欲しいなら、shadcn/ui が 2026 年 10 月に AI Elements を公開しました。Chat、Prompt Input、Reasoning、Tool を含む 21 個のパターンで、プロジェクトにコピーして自分で組み合わせます。Vercel の以前の AI Elements ライブラリは、これらに置き換わる形で 2026 年 11 月末に非推奨になります。

上記の状態まで作り込まれた、組み立て済みの画面が欲しいなら、それが HextaUI のブロックです。停止、再試行、バージョンを備えた Chat Thread、AI SDK のすべての状態と承認に対応した Tool Calls、書きかけの構文を決して見せない Streaming と Markdown、そして CJK 入力、アップロード、キュー、下書きを扱う Prompt Input です。Prompt Input は無料です。残りは一回限りの購入です。すべて AI SDK のメッセージパーツを読みますが、SDK を import するものは一つもないので、変換できるバックエンドならどれでも動きます。

何もインストールする前に、チャットアプリとして組み上がった全体を hextaui.com/blocks/hexta-ai で試せます。

よくある質問

これらのパターンを使うには Vercel AI SDK が必要ですか? いいえ。役に立つのはメッセージパーツの形です。出力をパーツに変換できれば、どんなバックエンドでも動きます。

IME 使用中の Enter はどう扱いますか? keydown ハンドラーで event.nativeEvent.isComposing を確認し、早期に return します。

ストリーミング中のテキストはトークンごとにライブリージョンを更新すべきですか? いいえ。完了時か文の区切りで読み上げてください。さもないとスクリーンリーダーが使いものにならなくなります。

停止した返信はどうなりますか? 途中までのテキストを残し、停止済みと印を付けます。ユーザーがすでに読んだものを削除してはいけません。