Gemini Nano でブラウザ完結のメール下書きツールを作る — Chrome Prompt API ハンズオン
何を作ったか
てちこまツール — メール下書き (端末内 AI) を追加しました。
箇条書きを放り込むと、日本語のビジネスメール本文に整えてくれるだけのツールなのですが、中身は Chrome に組み込まれた Gemini Nano (window.LanguageModel) を呼んでいて、入力も生成結果もブラウザの外には出ていきません。
ChatGPT や Claude に貼りたいけど、
- 取引先名・顧客名が混ざってる
- 金額が書いてある
- 社内ドキュメントの断片が入ってる
みたいな理由で「ちょっと気が引けるな…」ってなって結局自分で書く、というパターンがわりとあります。そこを気軽にさばきたかった、というのが動機です。
Chrome Prompt API を 3 行で
- Chrome 138 以降にビルトインされた、端末内で動く LLM (= Gemini Nano) への JS API
window.LanguageModel経由で、セッションを作ってprompt()/promptStreaming()を呼ぶだけ- モデル本体は初回に一度だけダウンロードされて、以降はオフラインでも動く
API キーも課金もなく、入力は一切サーバーに飛びません。そのかわり Chrome 側で一定のハード要件があったり、まだ Origin Trial ベースの機能だったり、と癖はあります。
Chrome 側のセットアップ
ツール側で検出して案内を出していますが、要点だけ書いておきます。
- Chrome 138+ にアップデート (
chrome://versionで確認) - 2 つのフラグを有効化
chrome://flags/#optimization-guide-on-device-model→ Enabled (まずはこれでOK)chrome://flags/#prompt-api-for-gemini-nano→ Enabled
- Chrome を再起動
chrome://componentsを開いて「Optimization Guide On Device Model」の「アップデートを確認」をクリック → モデル本体 (約 1.5〜2.4GB) がダウンロードされるchrome://on-device-internalsでモデル状態やエラー詳細が見られる
ハード要件の目安はストレージの空き 22GB、RAM 16GB、GPU VRAM 4GB 以上あたりです。満たさない端末で unavailable のままになる場合は、フラグ 1 つ目を Enabled BypassPerfRequirement に切り替えるとハード要件を緩められます (ただし性能次第では動作が重くなります)。
モバイル Chrome、他社ブラウザでは使えません。2026 年 4 月時点ではまだデスクトップ Chrome 限定です。
実装の主要パーツ
TypeScript + Astro で書いていますが、ポイントはどのフレームワークでも共通です。
1. availability を 4 ステートで見る
LanguageModel.availability() は 'available' | 'downloadable' | 'downloading' | 'unavailable' のどれかを返します。
export type Availability = 'unavailable' | 'downloadable' | 'downloading' | 'available';
export function isSupported(): boolean {
return typeof globalThis !== 'undefined' && typeof globalThis.LanguageModel !== 'undefined';
}
export async function checkAvailability(): Promise<Availability> {
if (!isSupported()) return 'unavailable';
try {
return await globalThis.LanguageModel!.availability({
expectedInputs: [{ type: 'text', languages: ['ja'] }],
expectedOutputs: [{ type: 'text', languages: ['ja'] }],
});
} catch {
return 'unavailable';
}
}
UI 側はこの 4 ステートにそれぞれ対応する表示を用意します。
available: そのまま生成ボタン活性化downloadable: 「モデルを準備する」ボタンを見せて、押されたらcreate()するdownloading: プログレス表示 + 2 秒ごとにavailability()をポーリングして状態遷移を検知unavailable: 諦めてもらうか、Chrome 側セットアップ手順を案内
downloading 状態への遷移は別タブでの操作などでも起こり得るので、最初から downloading を受けても慌てない作りにしておくと安全でした。
2. モデルダウンロードは明示クリック起点にする
Chrome の仕様で、モデルのダウンロード開始にはユーザー操作が必要です。ページロード時に自動で create() を呼ぶと弾かれるので、downloadable のときだけボタンを表示して、そのクリックで LanguageModel.create() を呼びます。
monitor オプションで進捗 (0〜1 の ratio) が取れるので、そのまま <progress> に流し込みます。
export async function createSession(
onDownloadProgress?: (ratio: number) => void,
): Promise<LMSession> {
if (!isSupported()) throw new Error('この端末では Chrome の AI API が利用できません');
return globalThis.LanguageModel!.create({
expectedInputs: [{ type: 'text', languages: ['ja'] }],
expectedOutputs: [{ type: 'text', languages: ['ja'] }],
initialPrompts: [{ role: 'system', content: SYSTEM_PROMPT }],
monitor: onDownloadProgress
? (m) => m.addEventListener('downloadprogress', (e) => onDownloadProgress(e.loaded))
: undefined,
temperature: 0.6,
topK: 3,
});
}
3. システムプロンプトは initialPrompts で固定する
Nano はコンテキストが狭い (6,144 トークンくらい) ので、毎回のユーザー入力にシステム指示を混ぜていると余白を食い潰します。セッション作成時に initialPrompts: [{ role: 'system', content: ... }] で一度だけ渡しておくと、その後の prompt() では指示を省略できて気持ちいいです。
用途 (謝罪・催促・日程調整など) とトーン (丁寧・標準・親しみ) はシステムに固定せず、毎回のユーザープロンプトに差し込むようにしました。こうしておくと、同じセッションを使い回せます。
4. 生成はストリーミング + AbortController
prompt() でも動きますが、体感速度は promptStreaming() の方が断然よいです。返ってくるのは ReadableStream<string> なので、読みながら DOM に追記していくだけで「AI っぽい」出力になります。
export async function draftMail(
session: LMSession,
input: MailInput,
onChunk: (text: string) => void,
signal?: AbortSignal,
): Promise<string> {
const stream = session.promptStreaming(buildUserPrompt(input));
const reader = stream.getReader();
let full = '';
try {
while (true) {
if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
const { value, done } = await reader.read();
if (done) break;
if (value) {
full += value;
onChunk(value);
}
}
} finally {
reader.releaseLock();
}
return full;
}
AbortController を噛ませて「停止」ボタンで途中キャンセルできるようにしておきます。Nano は小さいとはいえ、端末によっては数秒〜十数秒の生成になるので、止められる UI は大事でした。
5. 出力フォーマットは擬似テンプレで縛る
Nano はあまり賢くない (クラウドの大型モデルに比べて) ので、自由に書かせると前置きや Markdown 記法を混ぜてきます。出力フォーマットをユーザープロンプト末尾で明示すると安定しました。
出力フォーマット (厳守):
件名: <件名>
<本文>
--- 箇条書き ---
・来週の定例の議題確認
・資料は事前共有
temperature: 0.6 / topK: 3 で揺れを抑えめ、くらいが自分の肌感ではバランスよかったです。
ハマったところ
availability が downloadable でも create() が失敗する
Chrome の user gesture チェックが入っているので、ページロード直後に自動で create() を呼ぶと NotAllowedError になります。ボタンクリックのハンドラ内で呼ぶようにしたら解消しました。
WSL の Chrome では VRAM 要件で unavailable が返る
開発中に気付いたのですが、WSL 内の Chrome だと GPU 要件を満たさず unavailable になることがありました。実機の Chrome で試す or フラグ optimization-guide-on-device-model を BypassPerfRequirement にしておくと、ハード要件の一部を緩められます。
コンテキストが狭いので長文は苦手
6K トークン程度なので、長い箇条書きを一気に食わせると生成が破綻します。「1 通分を 1 リクエストで」くらいの粒度が安全でした。コンテキスト溢れを UI 側で事前検知するところまではまだ入れていません (TODO)。
日本語の敬語のゆらぎはわりと出る
「お世話になっております」が「お世話になります」になったり、結びの挨拶が微妙だったりはします。丁寧にしたいときは文体を「丁寧」にして、それでも合わなければ生成結果を手直しするのが現実的でした。
使い方
操作は 3 ステップです。
- 用途を選ぶ (日程調整 / 問い合わせ返信 / 謝罪 / 催促 / 社外カジュアル)
- 文体を選ぶ (標準 / 丁寧 / 親しみ)
- 箇条書きを書く → 「メール本文を作成」
件名は空欄なら AI が内容から考えてくれます。自分で案がある場合は入れておくと、それをベースに整えてくれます。
追加の指示で微調整する
用途テンプレで物足りないときは「追加の指示を書く (任意・上級者向け)」を開くと、自由記述のプロンプト欄が出てきます。
結論を先頭に書く150字以内にまとめる署名は「てちこま」で締める
みたいな指示を入れておくと、そのまま毎回のプロンプトに織り込まれます。ChatGPT で言うところの「カスタム指示」の超シンプル版くらいの感覚です。
どこにも送信されないのか
要点としては、
- 生成部分は
window.LanguageModelを直接叩いているだけで、自前のバックエンドは経由していません - 入力と生成結果は
localStorageに再訪時の復元用として保存されるだけです - AdSense と Google Analytics は他のページと同条件で入っています (メール本文自体は送られません)
オフライン確認もできて、機内モードにしてもちゃんと生成されます。
今後やりたいこと
- コンテキスト長の事前チェック (長すぎる箇条書きを UI で警告する)
- 過去の生成履歴のローカル保存 + 呼び出し
- 用途テンプレをユーザー側で追加・編集できるようにする
- 他の「端末内 AI と相性がいい」ネタを足していく
Gemini Nano は「すごいモデル」ではないですが、「機密だからクラウド AI には貼れないな」という場面ではむしろ正解でした。同じくらいの粒度のツール (ログ要約・コミットメッセージ・エラー解説など) を順次試していく予定です。
ブラウザ完結の AI ツールをやってみたい方の参考になれば嬉しいです。