実践assistant-ui: AIチャットを速やかに構築する
この記事はこの記事はヌーラボブログリレー2025夏の11日目として投稿しています。
本記事では、AIチャットアプリケーションのUI開発を効率化するライブラリ「assistant-ui」について、その概要から具体的な活用例、サンプルだけだとわかりにくい具体的な機能についての実装を解説します。
assistant-uiとは
asisitant-uiとは、AIチャットのUIを構築するために特化したTypeScript/Reactライブラリです。デザインとカスタマイズ性で使い勝手が良くコンポーネント不配布のデファクトであるshadcn/ui とTailwind CSSをベースに構築されており、サンプルを見てわかるようにまるでChatGPTのような洗練されたチャットUIを、最速でアプリケーションに組み込むことができます。
同じようなライブラリはいくつかありますが、あくまで筆者目線ですがサンプルの見た目が一番好みです。
何が作れる?
Examplesをみてもらえたらわかりますが、assistant-uiを使えば簡単にChatGPTのようなAIとのチャットUIを簡単に構築できます。
- 基本的なAIチャット機能: メッセージの送受信、ストリーミング表示(AIがリアルタイムで回答を生成する様子)、Markdownやコードハイライトのサポートなど、チャットUIに求められる基本的な要素が網羅されています
- 自前のバックエンドとの連携が簡単に行えます
- ツール呼び出しのフロントエンド対応: ユーザーの指示に基づき、LLMが「特定のツール(関数)を使うべき」と判断した場合に、フロントエンド側で定義された処理を実行させることができます。これにより、AIとの対話を通じてアプリケーションの機能を直接操作する、より高度な連携が実現できます。
cliも充実しており、数回のコマンドで簡単に綺麗なチャットUIが構築できます。
一方で少し込み入ったことを考えるとドキュメントを見ても難しい部分があるのとAIでのバイブコーディングでは突破できないことも多々あるため腰を据えてドキュメントを読んで実装してみることをお勧めします。
じゃあ難しくて使うべきではないかと思う方もいるかもしれませんが、ほとんどUIの実装ロジック部分を考えることなくChatGPTのように世の中のAIチャットにおけるスタンダートの動きをしてくれるため使わないてはないです。既存ライブラリ使うと難しそうだからとこの辺を自分たちで実装しようとすると細かい部分まで車輪の再発明になってしまうので要注意です。
ちゃんと理解すれば結構自由度高く現場に導入できるため頑張る価値は必ずあります。
AIチャットを導入する現場でどう使うか
さて色々説明してきましたがここからは複数の角度でassistant-uiを現場でどうやって導入するかを考えていきます。
今回色々考えた実装をこちらに残してますので、もし興味のある方は参照してください。
assistant-uiを既存画面内に組み込む
assistant-uiはTailwindとshadcnによるコンポーネントを推奨として提供をしています。
https://www.assistant-ui.com/docs/getting-started
とはいえ既存の画面にAIのチャットを埋め込みたい場合TailwindのCSSを分けたい場面もあるかと思います。
そのような時は次の2点を考えます。
- AI部分をウィジェット的に読み込んで
web-component
での提供 - Tailwind CSSは既存画面からの影響を避けるためprefixを追加した上で
web-component
のadoptedStyleに埋め込む
とすることで上手く既存の画面に載せることができます。
assistant-uiのaddコマンドはshadcnのコマンドをラップしているため
npx assistant-ui add thread thread-list
GettingStartedの画面通りのコマンドを実行するとcomponnent.jsonが生成されるので
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": "assistant" <- ここが大事
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
Prefixの項目を修正して再度 add
することでコンポーネントのclass名にプレフィックスが付与されます。
その上でweb-componentを使えばassistant-ui側のTailwindの情報をグローバルのhtmlへの汚染を防ぎつつ安心して使うことができます。
じゃあprefixいらないじゃんという思った方へ。グローバルなcssがassistant-ui側のcssを汚染しないのでprefixもweb-component内としてもとても重要です。こうすることで組み込む際にcssに関しては心配する必要がなくなるので、完全に別で開発しても結合時に崩れるみたいなリスクが少なくなります。
サンプル実装はこちら
そもそもTailwindを使わずにstyled-componentやcss in js を使えばCSSの汚染をある程度何も考えずに実装することができるんで、楽かもしれません。何も考えずにTailwindを導入するとどうしてもグローバルに影響してしまうのでこの辺は現場の状況を見て考えるといいと思います。
assistant-uiがTailwindを推奨にしているので少し心配になるかとは思いますが、assistant-uiはコンポーネントのUIそのものを実装しているというよりデータ構造やHTMLの構造を提供しているためCSSをどう適用してデザインをどうするかというのは本質的にこのライブラリの範囲外なためあまり心配しなくてもいいかと思います。
AIやその他のデータは自前のAPIサーバーを通じて行う
Assitant-uiではvercel cloud上で上手く動くように設計されていますが、自前のAPIも簡単に繋ぎ込めるように実装されています。
https://www.assistant-ui.com/docs/runtimes/pick-a-runtime
ドキュメントを見てもらえればわかると思いますが、多様なruntimeがあるため様々なユースケースに対応できるかと思います。
とはいえやりたいことはもっとシンプルでチャットの履歴を自前のDBに保持したいのでそこを上手くできればいいだけという方は多いかと思います。私も大体そうです。
そのような方はドキュメントを見ながらruntimeを選んでいけばいいのですが、大体のケースでLocalRuntime、ExternalStoreRuntimeを使用することになります
https://www.assistant-ui.com/docs/runtimes/custom/local
ExternalStoreRuntimeの方がフロントエンドでのステート管理をユーザーがしなければならない代わりに柔軟性が高いのですが、ここではフロントエンドの複雑な処理はしないためLocalRuntimeを拡張した`useRemoteThreadListRuntime` を使用し構築していきます。
https://www.assistant-ui.com/docs/runtimes/custom/local#custom-runtime-implementation
あとは上記を見ながら実装していくだけなんですが、せっかくなので実際のサンプルを作ってみました。
- スレッド(会話の履歴)が複数
- メッセージはDBに保存
- スレッドもDBに保存
基本的に実装してみると楽に実装できることがわかります。DBのスキーマも公開されているので、生成AIにてDBに起こしています。
AIに作ってもらったプロトタイプなので動いてない部分もあるんですが、もう修正する気が起きなかったのでそのままにしてます。気になる人はこちらからサンプルコードをみてみてください
Streamingの表示
やはりサーバーサイドでAIのエンドポイントを叩いてそれをそのままフロントエンドにストリーミングで流すことを考えてみましょう。
この場合サーバーサイドとフロントエンドでAI SDKを使用して、useDataStreamRuntimeを使えばかなり楽にstreaming処理を行えます。
https://www.assistant-ui.com/docs/api-reference/integrations/react-data-stream
ここではさらに前述のようにスレッドのリストを自前のDBにもつ場合を考えていきましょう。現時点ではドキュメントではuseDataStreamRuntimeとuseRemoteThreadListRuntimeの統合方法は記述がなかったっため、おそらく一緒には使えないだろうということでなるべくstream処理はAI SDKの方に逃すことを考えます。
- ServerサイドではAI SDKを利用しAIとのstream処理を行う
- フロントエンドでもAI SDKを利用しstreamされたテキストをハンドルする
データの処理周りはなるべくAI SDKの寄せた方が今後のメンテナンスコストは低そうに思えます。しかし以下のようにassistant-uiのexampleではstreamingの処理を自分で処理している例が多いため注意が必要です。
https://www.assistant-ui.com/docs/runtimes/custom/local
const MyModelAdapter: ChatModelAdapter = {
async *run({ messages, abortSignal, context }) {
const stream = await backendApi({ messages, abortSignal, context });
let text = "";
for await (const part of stream) {
text += part.choices[0]?.delta?.content || "";
yield {
content: [{ type: "text", text }],
};
}
},
};
今回実装済みのサンプルでもAI SDKを使用して連携しています。
Tool Callingの表示
エージェントには必須な挙動である、LLMの外の状態を引っ張るTool Callingの表示の表示もassistant-uiなら簡単に行えます。
LLMに渡すTool Callingの関数について
assistant-uiではフロントエンドのTool CallingをバックエンドでのTool Callingとは別に定義でき、LLMの実行時に合わせて関数として登録することができます。
https://www.assistant-ui.com/docs/guides/Tools
バックエンド、フロントエンドのTool Callingを合わせて登録についてはAI SDKを使うことで実現が可能です。
https://www.assistant-ui.com/docs/runtimes/ai-sdk/use-chat#setup-a-backend-route-under-apichat
私個人的にサッと考えつくユースケースはバックエンドでのAPIなどの呼び出しでしたが、よく考えるとGitHub CopilotのようなTool Callingがフロントエンドで活躍する場面ではフロントエンドTool Callingが役に立ちそうです。
以下は公式サイトに載っているスクリーンショットの撮影関数です。このようにフロントエンド特有の挙動を簡単にバックエンドのTool Callingと同じように使えるのは魅力的です。
const screenshotTool = tool({
description: "Capture a screenshot of the current page",
parameters: z.object({
selector: z.string().optional(),
}),
execute: async ({ selector }) => {
const element = selector ? document.querySelector(selector) : document.body;
const screenshot = await captureElement(element);
return { dataUrl: screenshot };
},
});
const ScreenshotTool = makeAssistantTool({
...screenshotTool,
toolName: "screenshot",
});
さてこのフロントエンドTool Callingについてはサンプルでも試してみましたが、バックエンドのAI SDKとフロントエンドのassistant-uiで型定義を合わせる作業が一筋縄では行かずに、失敗しました。こんなふうにドキュメントに書かれていることを素直に実装してもなかなかうまく行かないということは結構あります。
Tool CallingのUI表示について
makeAssitantToolUIを使えば簡単に実装を行うことができます。
https://www.assistant-ui.com/docs/copilots/make-assistant-tool-ui
サンプルの例でも実装してますが、messageの種類がtool-callなら勝手にassistant-uiの方で調整してくれます。
{
status: { type: "complete", reason: "stop" },
content: [{
type: "tool-call",
toolCallId: p.toolCallId,
toolName: toolNameWithoutPrefix,
args: safeArgs as any ,
argsText: JSON.stringify(safeArgs),
result: p.output
}]
}
おこはあまり迷わずに行えます。
Human in the loopについて
参照系のTool Callingについては人間の承認は必要がないことが多いですが、更新系については多くの場面で承認が必要だと思います。その仕組みをhuman-in-the-loopと呼び、LLMの作業の中に人間が埋め込まれているようなそんな状態を表しています。
ドキュメントを見る限りassistant-uiにはその仕組みがありそう。。。でしたが手元では結局うまく動きませんでした。正直何を実現しているオプションなのかの理解すらできなかったというのが現状です。
こちらに関してはフロントエンドでのTool Callingでのhuman-in-the-loopでした。
https://www.assistant-ui.com/docs/guides/Tools#human-in-the-loop-tools
https://www.assistant-ui.com/docs/runtimes/custom/local#human-in-the-loop-approval
const runtime = useLocalRuntime(MyModelAdapter, {
unstable_humanToolNames: ["delete_file", "send_email"],
});
上記二つについてはおそらくフロントエンドTool Callingのような気がしています。バックエンドでのTool Callingを止める方法については実際にはバックエンドの実装に依存します。
今回のサンプルではバックエンドでAI SDKを使用してhuman-in-loop的なTool Callingを実装しようとしたのですが、assistant-uiとうまく結合できずに失敗しています。
https://github.com/trknhr/assistant-ui-example/blob/main/my-api/src/app/api/chat/route.ts#L28-L76
AI SDKのhuman-in-the-loopのリンクはこちら
https://ai-sdk.dev/cookbook/next/human-in-the-loop#intercept-Tool Calling
時間の都合もありサンプルが実行できなかった時点で諦めましたが、型をちゃんと合わせたりしたらもっと楽にできるかもしれません。
サンプル全体はこちら
余談:実装面でのAIコーディングとの食い合わせの悪さについて
assitant-uiのサンプルやドキュメントはたくさんあるんですが、少し込み入ったことをしようとすると人間がドキュメント読んでも混乱してしまうことがありました。こういう時はAIの方がうまくできると思いきや、assistant-uiやAI SDKはAIにとっても新しいライブラリであるためか、やりたいことが少し複雑になってくると実装ミスや型のミスなどが散見されました。結局のところドキュメントを読みつつ難解なところはAIに食わせながら実際に動かしていくという昔ながらな方法でなんとか動くサンプルが構築できました。
もう少し効率的な方法があれば良かったのですが、四苦八苦している最中にドキュメントを全て読んでしまったので、AI全盛の時代にコスパで負けて悔しい気持ちでいっぱいです。
まとめ
assistant-uiは、現代的なAIチャットアプリケーションを開発するための強力なツールキットです。その洗練されたコンポーネントとAI連携を前提とした設計により、開発者は複雑なUI実装の悩みから解放され、より創造的で価値のある機能開発に時間を使うことができます。
本番環境での利用はもちろん、アイデアを素早く形にするプロトタイピングにおいても、assistant-uiはあなたの強力な味方となると思います。ぜひ明日から。いや。今日からassistant-uiを使ってみてください。
Discussion