WebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
ブラウザだけで動く AI チャットを、たった 100 行のコードで実装できるとしたら、試してみたくなりませんか?
WebLLM を使えば、サーバー不要でプライバシーを守りながら、本格的な AI チャット体験を提供できます。本記事では、初心者の方でも迷わず実装できるよう、段階的に解説していきます。実際に動くコードを書きながら、WebLLM の使い方を体験してみましょう。
背景
チャット UI が必要な理由
AI モデルを動かすだけでなく、ユーザーが自然に対話できるインターフェースが不可欠です。チャット UI は、AI との対話を直感的で親しみやすいものにしてくれます。
従来のサーバーベースの AI チャットでは、以下のような構成が一般的でした。
図の意図: 従来型のチャットシステムとWebLLMの違いを示します。
mermaidflowchart LR
user["ユーザー"] -->|メッセージ送信| frontend["フロントエンド"]
frontend -->|API リクエスト| backend["バックエンド<br/>サーバー"]
backend -->|推論依頼| ai["AI モデル<br/>(GPU サーバー)"]
ai -->|応答| backend
backend -->|JSON| frontend
frontend -->|表示| user
style backend fill:#ffcccc
style ai fill:#ffcccc
サーバー側での処理が必要で、インフラコストやプライバシーの懸念がありました。
WebLLM によるシンプル化
WebLLM を使えば、すべてブラウザ内で完結します。サーバー構築やバックエンド開発が不要になり、フロントエンドだけで完全な AI チャットを実装できるのです。
図の意図: WebLLMを使ったシンプルな構成を示します。
mermaidflowchart LR
user["ユーザー"] -->|入力| chat["チャット UI<br/>(HTML/JS)"]
chat -->|メッセージ| webllm["WebLLM<br/>エンジン"]
webllm -->|WebGPU| gpu["ローカル GPU"]
gpu -->|トークン| webllm
webllm -->|応答| chat
chat -->|表示| user
style chat fill:#ccffcc
style webllm fill:#ccffcc
style gpu fill:#ccffcc
図で理解できる要点:
- バックエンドサーバーが不要
- すべてブラウザ内で処理が完結
- ローカル GPU を活用した高速な応答
課題
初心者が直面する実装の壁
WebLLM の公式ドキュメントは充実していますが、実際に動くチャット UI を作るには、いくつかのハードルがあります。
図の意図: 実装時に考慮すべきポイントを示します。
mermaidflowchart TD
start["チャット UI 実装"] --> init["エンジンの<br/>初期化"]
start --> ui["UI の構築"]
start --> stream["ストリーミング<br/>応答"]
start --> error["エラーハンドリング"]
init --> init_solution["CreateMLCEngine<br/>の呼び出し"]
ui --> ui_solution["メッセージ履歴<br/>の管理"]
stream --> stream_solution["chunks.asyncGenerator<br/>の利用"]
error --> error_solution["try-catch による<br/>エラー処理"]
style start fill:#ffffcc
style init_solution fill:#e1f5ff
style ui_solution fill:#e1f5ff
style stream_solution fill:#e1f5ff
style error_solution fill:#e1f5ff
モデルの初期化タイミング
WebLLM エンジンの初期化には時間がかかります。ユーザーが初回メッセージを送信してから初期化すると、待ち時間が長くなってしまいます。
メッセージ履歴の管理
チャット形式では、過去のメッセージを記憶して文脈を保つ必要があります。どのようにメッセージ履歴を管理し、API に渡すかが重要です。
ストリーミング応答の実装
AI の応答をリアルタイムに表示するストリーミング機能は、ユーザー体験を大きく向上させます。しかし、非同期処理やイテレーターの扱いに慣れていないと、実装が難しく感じるでしょう。
解決策
100 行で実装するアプローチ
これらの課題を解決するため、以下のシンプルな設計で実装します。
| # | 項目 | アプローチ |
|---|---|---|
| 1 | HTML 構造 | 最小限の要素(入力欄、送信ボタン、メッセージ表示) |
| 2 | エンジン初期化 | ページロード時に非同期で初期化 |
| 3 | メッセージ履歴 | JavaScript の配列で管理 |
| 4 | ストリーミング応答 | for await ループで chunk を逐次表示 |
| 5 | エラーハンドリング | try-catch でエラーを捕捉し、ユーザーに通知 |
この設計により、コードをシンプルに保ちながら、実用的なチャット UI を実現できます。
コードの全体構成
実装は以下の 3 つのパートに分かれます。
- HTML 構造 - チャット UI の骨組み
- スタイリング - 見た目の調整
- JavaScript ロジック - WebLLM の制御とメッセージ処理
それぞれを段階的に見ていきましょう。
具体例
HTML の準備
まず、チャット UI の骨組みとなる HTML を作成します。
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebLLM チャット</title>
<style>
/* スタイルは次のセクションで追加 */
</style>
</head>
<body>
<div id="app">
<!-- チャット画面のコンテナ -->
</div>
</body>
</html>
このシンプルな構造から始めます。app という ID の div がチャット UI 全体を包みます。
UI コンポーネントの配置
次に、メッセージ表示エリアと入力欄を追加します。
html<div id="app">
<div id="chat-container">
<div id="messages">
<!-- メッセージがここに表示される -->
</div>
<div id="input-area">
<input
type="text"
id="user-input"
placeholder="メッセージを入力..."
/>
<button id="send-btn">送信</button>
</div>
</div>
</div>
メッセージ履歴を表示する messages エリアと、ユーザー入力用の input-area を配置しました。
CSS スタイリング
見やすいチャット UI にするため、基本的なスタイルを追加します。
cssbody {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
}
#app {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
padding: 20px;
}
ページ全体を中央に配置し、清潔感のある背景色にしています。
チャットコンテナのスタイル
チャット画面の枠組みにスタイルを適用します。
css#chat-container {
width: 100%;
max-width: 600px;
height: 80vh;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
角丸で影をつけることで、モダンなデザインになります。
メッセージ表示エリアのスタイル
メッセージが表示される部分にスクロール機能を追加します。
css#messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
word-wrap: break-word;
}
.user-message {
background-color: #007bff;
color: white;
align-self: flex-end;
}
.ai-message {
background-color: #e9ecef;
color: #333;
align-self: flex-start;
}
ユーザーメッセージは右寄せの青、AI メッセージは左寄せのグレーで区別しています。
入力エリアのスタイル
入力欄と送信ボタンのスタイルを整えます。
css#input-area {
display: flex;
padding: 16px;
border-top: 1px solid #e0e0e0;
gap: 8px;
}
#user-input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
#send-btn {
padding: 12px 24px;
background-color: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
#send-btn:hover {
background-color: #0056b3;
}
#send-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
ボタンにホバー効果と無効状態のスタイルを追加し、ユーザビリティを向上させています。
WebLLM ライブラリの読み込み
JavaScript で WebLLM を使用するため、CDN から読み込みます。
html<script type="module">
import { CreateMLCEngine } from "https://esm.run/@mlc-ai/web-llm";
// この後に実装コードを記述
</script>
type="module" を指定することで、ES モジュールとして WebLLM をインポートできます。
グローバル変数の定義
エンジンとメッセージ履歴を管理する変数を用意します。
javascriptlet engine = null;
let messages = [];
const messagesContainer = document.getElementById("messages");
const userInput = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");
これらの変数で、WebLLM エンジンとチャット状態を保持します。
エンジンの初期化処理
ページ読み込み時に WebLLM エンジンを初期化します。
javascriptasync function initEngine() {
try {
// 初期化中のメッセージを表示
addMessage("ai", "AI を初期化しています...");
// WebLLM エンジンを作成
engine = await CreateMLCEngine("Llama-3.1-8B-Instruct-q4f32_1");
// 完了メッセージを表示
addMessage("ai", "準備完了!メッセージを送信してください。");
sendBtn.disabled = false;
} catch (error) {
addMessage("ai", `エラー: ${error.message}`);
}
}
初期化には数秒かかるため、ユーザーに状態を知らせるメッセージを表示しています。
メッセージ表示関数
チャット画面にメッセージを追加する関数です。
javascriptfunction addMessage(role, content) {
const messageDiv = document.createElement("div");
messageDiv.className = `message ${role}-message`;
messageDiv.textContent = content;
messagesContainer.appendChild(messageDiv);
// 最新メッセージまでスクロール
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
新しいメッセージが追加されたら、自動的にスクロールして表示します。
メッセージ送信処理
ユーザーがメッセージを送信したときの処理を実装します。
javascriptasync function sendMessage() {
const userMessage = userInput.value.trim();
if (!userMessage || !engine) return;
// ユーザーメッセージを表示
addMessage("user", userMessage);
messages.push({ role: "user", content: userMessage });
// 入力欄をクリア&無効化
userInput.value = "";
sendBtn.disabled = true;
// AI の応答を取得
await getAIResponse();
// 送信ボタンを再度有効化
sendBtn.disabled = false;
}
ユーザーの入力を履歴に追加し、AI からの応答を待ちます。
ストリーミング応答の実装
AI の応答をリアルタイムで表示する処理です。
javascriptasync function getAIResponse() {
try {
// AI メッセージ用の空の div を作成
const aiMessageDiv = document.createElement("div");
aiMessageDiv.className = "message ai-message";
messagesContainer.appendChild(aiMessageDiv);
let fullResponse = "";
// ストリーミングで応答を取得
const chunks = await engine.chat.completions.create({
messages: messages,
stream: true,
});
// chunk を逐次処理
for await (const chunk of chunks) {
const content = chunk.choices[0]?.delta?.content || "";
fullResponse += content;
aiMessageDiv.textContent = fullResponse;
// スクロール位置を更新
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 履歴に追加
messages.push({ role: "assistant", content: fullResponse });
} catch (error) {
addMessage("ai", `エラー: ${error.message}`);
}
}
for await ループを使い、トークンが生成されるたびに画面を更新しています。これにより、ChatGPT のようなタイピングエフェクトが実現できます。
イベントリスナーの設定
送信ボタンと Enter キーにイベントを紐付けます。
javascriptsendBtn.addEventListener("click", sendMessage);
userInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
sendMessage();
}
});
Enter キーでもメッセージを送信できるようにすることで、使い勝手が向上します。
ページ読み込み時の初期化
最後に、ページが読み込まれたらエンジンを初期化します。
javascript// ページ読み込み時に実行
initEngine();
これで、ユーザーがページにアクセスした瞬間から、自動的に WebLLM エンジンが起動し始めます。
完成したコードの全体像
ここまでのコードを 1 つにまとめると、約 100 行で動作するチャット UI が完成します。
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebLLM チャット</title>
<style>
body { margin: 0; padding: 0; font-family: 'Segoe UI', sans-serif; background-color: #f5f5f5; }
#app { display: flex; justify-content: center; align-items: center; height: 100vh; padding: 20px; }
#chat-container { width: 100%; max-width: 600px; height: 80vh; background: white; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; }
#messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.message { padding: 12px 16px; border-radius: 8px; max-width: 80%; word-wrap: break-word; }
.user-message { background-color: #007bff; color: white; align-self: flex-end; }
.ai-message { background-color: #e9ecef; color: #333; align-self: flex-start; }
#input-area { display: flex; padding: 16px; border-top: 1px solid #e0e0e0; gap: 8px; }
#user-input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
#send-btn { padding: 12px 24px; background-color: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
#send-btn:hover { background-color: #0056b3; }
#send-btn:disabled { background-color: #ccc; cursor: not-allowed; }
</style>
</head>
<body>
<div id="app">
<div id="chat-container">
<div id="messages"></div>
<div id="input-area">
<input type="text" id="user-input" placeholder="メッセージを入力..." />
<button id="send-btn" disabled>送信</button>
</div>
</div>
</div>
<script type="module">
import { CreateMLCEngine } from "https://esm.run/@mlc-ai/web-llm";
let engine = null;
let messages = [];
const messagesContainer = document.getElementById("messages");
const userInput = document.getElementById("user-input");
const sendBtn = document.getElementById("send-btn");
async function initEngine() {
try {
addMessage("ai", "AI を初期化しています...");
engine = await CreateMLCEngine("Llama-3.1-8B-Instruct-q4f32_1");
addMessage("ai", "準備完了!メッセージを送信してください。");
sendBtn.disabled = false;
} catch (error) {
addMessage("ai", `エラー: ${error.message}`);
}
}
function addMessage(role, content) {
const messageDiv = document.createElement("div");
messageDiv.className = `message ${role}-message`;
messageDiv.textContent = content;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
async function sendMessage() {
const userMessage = userInput.value.trim();
if (!userMessage || !engine) return;
addMessage("user", userMessage);
messages.push({ role: "user", content: userMessage });
userInput.value = "";
sendBtn.disabled = true;
await getAIResponse();
sendBtn.disabled = false;
}
async function getAIResponse() {
try {
const aiMessageDiv = document.createElement("div");
aiMessageDiv.className = "message ai-message";
messagesContainer.appendChild(aiMessageDiv);
let fullResponse = "";
const chunks = await engine.chat.completions.create({
messages: messages,
stream: true,
});
for await (const chunk of chunks) {
const content = chunk.choices[0]?.delta?.content || "";
fullResponse += content;
aiMessageDiv.textContent = fullResponse;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
messages.push({ role: "assistant", content: fullResponse });
} catch (error) {
addMessage("ai", `エラー: ${error.message}`);
}
}
sendBtn.addEventListener("click", sendMessage);
userInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") sendMessage();
});
initEngine();
</script>
</body>
</html>
このファイルを保存してブラウザで開くだけで、完全に動作する AI チャットが起動します。
動作確認のポイント
実装したチャット UI を動かす際の確認事項です。
| # | 確認項目 | 内容 |
|---|---|---|
| 1 | ブラウザ対応 | Chrome、Edge、Safari の最新版を使用 |
| 2 | WebGPU の有効化 | chrome://flags で WebGPU が有効になっているか確認 |
| 3 | 初期化の待機 | モデルダウンロードには初回数分かかる(2 回目以降は高速) |
| 4 | メモリ使用量 | 開発者ツールの Performance タブで確認可能 |
| 5 | エラーメッセージ | コンソールでエラーログを確認 |
初回のモデルダウンロードは時間がかかりますが、IndexedDB にキャッシュされるため、2 回目以降は数秒で起動します。
カスタマイズのヒント
基本的なチャット UI ができたら、以下のような拡張が可能です。
モデルの切り替え
異なるモデルを試すには、CreateMLCEngine の引数を変更します。
javascript// より小さいモデルに変更(高速だが精度は下がる)
engine = await CreateMLCEngine("Llama-3.2-3B-Instruct-q4f16_1");
// より大きいモデルに変更(精度は上がるがメモリ消費が増える)
engine = await CreateMLCEngine("Llama-3.1-70B-Instruct-q4f16_1");
使用可能なモデルの一覧は、WebLLM の公式ドキュメントで確認できます。
ローディングインジケーター
AI が応答を生成している間、ローディング表示を追加できます。
javascriptasync function getAIResponse() {
// ローディング表示を追加
const loadingDiv = document.createElement("div");
loadingDiv.className = "message ai-message";
loadingDiv.textContent = "考え中...";
messagesContainer.appendChild(loadingDiv);
try {
// 既存のコード
// ...
// ローディング表示を削除
messagesContainer.removeChild(loadingDiv);
} catch (error) {
messagesContainer.removeChild(loadingDiv);
addMessage("ai", `エラー: ${error.message}`);
}
}
ユーザーに処理中であることを明示することで、待ち時間のストレスを軽減できます。
メッセージのクリア機能
会話履歴をリセットするボタンを追加することもできます。
javascriptfunction clearMessages() {
messages = [];
messagesContainer.innerHTML = "";
addMessage("ai", "会話をリセットしました。");
}
// HTML にボタンを追加
// <button onclick="clearMessages()">会話をクリア</button>
長い会話が続いた場合、履歴をクリアすることでメモリ使用量を抑えられます。
まとめ
本記事では、WebLLM を使ってブラウザ上で動作する AI チャット UI を 100 行で実装する方法を解説しました。
わずか 100 行のコードで、サーバー不要のプライバシー保護された AI チャットが実現できることに驚かれたのではないでしょうか。この実装をベースに、デザインのカスタマイズや機能追加を行うことで、より高度なアプリケーションに発展させることができます。
実装のポイントをまとめます。
| # | ポイント | 説明 |
|---|---|---|
| 1 | シンプルな構成 | HTML、CSS、JavaScript だけで完結 |
| 2 | CDN からの読み込み | npm インストール不要で即座に試せる |
| 3 | ストリーミング応答 | for await で chunk を逐次表示し、UX を向上 |
| 4 | エラーハンドリング | try-catch でエラーを捕捉し、ユーザーに適切に通知 |
| 5 | メッセージ履歴の管理 | 配列で履歴を保持し、文脈を維持 |
| 6 | 初期化の非同期処理 | ページロード時に自動で WebLLM エンジンを起動 |
WebLLM は、Web 技術だけで本格的な AI アプリケーションを構築できる画期的なライブラリです。この記事で実装したチャット UI をベースに、ぜひご自身のプロジェクトに応用してみてください。
モデルの選択、UI のカスタマイズ、追加機能の実装など、可能性は無限大です。ローカルで動作する AI の力を、あなたのアプリケーションに取り入れてみましょう。
関連リンク
articleWebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
articleWebLLM 中心のクライアントサイド RAG 設計:IndexedDB とベクトル検索の組み立て
articleWebLLM チートシート:主要 API・初期化・推論・ストリーム制御 一覧
articleWebLLM を 5 分で動かす:CDN 配信・モデル配置・WebGPU 有効化の最短ルート
articleWebLLM vs サーバー推論 徹底比較:レイテンシ・コスト・スケールの実測レポート
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleWebSocket Close コード早見表:正常終了・プロトコル違反・ポリシー違反の実務対応
articleStorybook 品質ゲート運用:Lighthouse/A11y/ビジュアル差分を PR で自動承認
articleWebRTC で高精細 1080p/4K 画面共有:contentHint「detail」と DPI 最適化
articleSolidJS フォーム設計の最適解:コントロール vs アンコントロールドの棲み分け
articleWebLLM 使い方入門:チャット UI を 100 行で実装するハンズオン
articleShell Script と Ansible/Make/Taskfile の比較:小規模自動化の最適解を検証
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来