オフラインでも要約が動けば、電波の届かない場所でも使ってもらえる。そう考えて Chrome のコンソールに手元のサンプルを貼り付けた日、返ってきたのは一行だけでした。
Uncaught ReferenceError: ai is not defined
綴りを疑い、フラグを確認し、Chrome を再起動しました。原因はそのどれでもありませんでした。ai という名前空間そのものが、もう存在しなかったのです。
Gemini Nano は「軽量な Gemini」と説明されることが多いのですが、実際につまずくのはモデルの性能ではありません。入口の選択と、モデルがまだ端末に無いかもしれない、という前提の扱い方 です。この2点を外すと、どれだけ正しいプロンプトを書いても一行も実行されません。
Chrome と Android、それぞれの現在地を整理します。
ai.languageModel はもう存在しません
初期の Chrome Built-in AI は ai や window.ai というグローバルの下に各機能がぶら下がる設計でした。ネット上に残るサンプルの多くは、この時期のものです。
現在は、機能ごとに独立したグローバルクラスへ移りました。
// 旧: ai is not defined で落ちます
const session = await ai.languageModel. create ({
systemPrompt: "あなたは親切なアシスタントです。"
});
// 現行: グローバルの LanguageModel を直接呼びます
const session = await LanguageModel. create ({
initialPrompts: [
{ role: 'system' , content: 'あなたは親切なアシスタントです。簡潔に回答してください。' }
]
});
システムプロンプトの渡し方も変わりました。systemPrompt という専用フィールドではなく、initialPrompts の先頭に role: 'system' のメッセージを置きます。会話の履歴を復元したいときも同じ配列に user と assistant を並べるだけで済むので、こちらのほうが素直だと感じます。
要約も同様です。
// 旧
const summarizer = await ai.summarizer. create ({ type: "tl;dr" });
// 現行
const summarizer = await Summarizer. create ({
type: 'tldr' , // tldr / key-points / teaser / headline
length: 'short' ,
format: 'plain-text' ,
});
type の値が "tl;dr" から 'tldr' へ変わっている点は見落としやすいところです。ここを直さずに名前空間だけ直すと、今度は無効なオプションで弾かれます。
各 API の提供状況も、ひとまとめに「Chrome に入った」と言える段階ではありません。2026年7月時点では次のように分かれています。
API Web ページから Chrome 拡張から
Translator / Language Detector Chrome 138 で安定版 Chrome 138 で安定版
Summarizer Chrome 138 で安定版 Chrome 138 で安定版
Prompt Chrome 148 で安定版 Chrome 138 で安定版
Writer / Rewriter デベロッパートライアル デベロッパートライアル
Proofreader デベロッパートライアル デベロッパートライアル
注目したいのは Prompt API の行です。Chrome 拡張からは 138 で安定版になった一方、通常の Web ページから使えるようになったのは 148。同じ API でも、拡張とページで解禁時期が10バージョン離れていました 。「拡張で動いたコードをそのままページに移したら動かない」という現象は、この差から生まれます。
Writer と Rewriter を製品の中核機能に据えるのは、まだ早いと私は考えています。デベロッパートライアル段階の API は、仕様変更の可能性を織り込んだ上で使うものです。詳細は Chrome の Built-in AI API ステータス一覧 が正本になります。
availability() を挟まないと create() は落ちます
名前空間を直しても、まだ動かないことがあります。次に見るのはここです。
Gemini Nano は Chrome に同梱されていません。API はブラウザに組み込まれていますが、モデルは初回利用時にオリジンごとに別途ダウンロードされます 。つまり、あなたのコードが走る瞬間、端末にモデルが無い可能性が常にあります。
そのため、現行の API は「使う前に状態を尋ねる」ことを前提にした形になっています。
// 1. 状態を確認する
// prompt() に渡すのと同じオプションを availability() にも渡すのが重要です
const availability = await LanguageModel. availability ({
expectedInputs: [{ type: 'text' , languages: [ 'ja' ] }],
expectedOutputs: [{ type: 'text' , languages: [ 'ja' ] }],
});
if (availability === 'unavailable' ) {
// 端末要件を満たしていません。クラウドへ回すか、機能を隠します
return fallbackToCloud ();
}
// 2. ユーザー操作を起点にダウンロードを開始する
// availability が 'downloadable' のとき、create() はユーザー操作が必要です
downloadButton. addEventListener ( 'click' , async () => {
const session = await LanguageModel. create ({
monitor ( m ) {
m. addEventListener ( 'downloadprogress' , ( e ) => {
progressBar.value = e.loaded * 100 ;
});
},
});
// 3. ここでようやく実行できます
const result = await session. prompt ( 'この記事を3行で要約してください。' );
console. log (result);
});
ここで見落とされがちなのが2つあります。
ひとつは availability() と prompt() に同じオプションを渡すこと 。言語やモダリティの組み合わせによっては非対応のことがあり、オプションを揃えないと「利用可能」と答えが返ったのに実行時に NotSupportedError が飛ぶ、という食い違いが起きます。
もうひとつは ユーザー操作(user activation)の要件 です。ページ読み込み直後に create() を呼ぶコードは、モデル未ダウンロードの端末で必ず失敗します。数 GB のダウンロードが、ユーザーの同意なく始まらないようにするための仕様です。開発機ではすでにモデルが入っているため通ってしまい、この失敗は本番で初めて表面化します。
個人開発でひとりで検証していると、この2つを飛ばしたコードを「動いた」と判断しかけます。私自身、そうなりかけました。手元の Chrome にモデルが、すでに入っていただけだったのです。オンデバイス AI の検証では、モデルが無い端末での初回体験こそが本番 だと考えるようになりました。
なお availability() が返すのは 'unavailable'・'downloadable'・'downloading'・'available' の4状態です。真偽値ではないので、if (availability) のような書き方をすると 'unavailable' を通してしまいます。
「名前があるか」から「使えるか」までを一関数に畳む
availability() を呼び出しのたびに書いていると、画面側の分岐がすぐに散らかります。実際に必要な判断は、機能を出すか、ダウンロードを促すか、クラウドへ回すか。この3つに収束しますので、私は入口をひとつの関数にまとめてから呼ぶようにしました。
最初に効くのは特徴検出です。古い Chrome や Android 版では、グローバルそのものが存在しません。名前の有無を確かめずに availability() を書くと、判定する前に ReferenceError で画面ごと落ちます。
const OPTS = {
expectedInputs: [{ type: 'text' , languages: [ 'ja' ] }],
expectedOutputs: [{ type: 'text' , languages: [ 'ja' ] }],
};
// 返すのは 'ready' | 'prompt-download' | 'cloud' の3つだけに固定します
export async function resolveOnDeviceMode () {
// 名前が無い環境(旧 Chrome / Android / iOS)はここで確定します
if ( ! ( 'LanguageModel' in self)) return 'cloud' ;
let state;
try {
state = await LanguageModel. availability ( OPTS );
} catch {
// 言語とモダリティの組み合わせが非対応のとき、ここへ落ちます
return 'cloud' ;
}
switch (state) {
case 'available' : return 'ready' ;
case 'downloading' : return 'prompt-download' ; // 進捗だけを見せて待ってもらいます
case 'downloadable' : return 'prompt-download' ; // ボタンを出し、ユーザー操作を待ちます
default : return 'cloud' ; // 'unavailable'
}
}
'LanguageModel' in self を先頭に置くだけで、Android 版 Chrome で踏んでいた例外が消えます。try/catch より前でなければ意味がありません。名前の無い環境では、availability() という呼び出し自体が書けないからです。
戻り値を3つに固定しておくと、画面側は素直になります。ready なら実行、prompt-download ならボタン、cloud なら黙って Gemini API へ。分岐が3本なら、テストも3通りで済みます。
セッションを使い回す設計では、clone() と中断の仕組みも早めに入れておくと安定します。
const base = await LanguageModel. create ({
initialPrompts: [{ role: 'system' , content: '分類ラベルだけを短く返してください。' }],
});
// 履歴を汚さずに枝分かれさせたいとき
const branch = await base. clone ();
let ac;
input. addEventListener ( 'input' , async () => {
ac?. abort (); // 前の推論を打ち切ります
ac = new AbortController ();
const label = await base. prompt (input.value, { signal: ac.signal });
render (label);
});
入力のたびに推論を投げる画面では、この打ち切りが効きます。中断しないまま次を投げると、古い結果が後から届いて表示が入れ替わります。初期プロンプトを持つセッションを作り直すより、土台を1つ保って clone() で枝を伸ばすほうが、待ち時間も短く収まりました。
ダウンロード中にタブを閉じられたら — 進捗の再入場を設計する
'downloadable' からボタンを押してもらった直後、実装が手薄になりやすい区間があります。数分かかるダウンロードの最中に、ユーザーがタブを閉じて戻ってくる場面です。
まず、downloadprogress が運んでくる値の形を押さえておきます。e.loaded は 0 〜 1 の割合で、総バイト数は届きません。「あと何 MB」という表示は作れませんので、割合と経過時間で見せることになります。
そして戻ってきたとき、availability() は 'downloading' を返すことがあります。ダウンロードはタブではなくブラウザ側で進んでいるためです。この状態で素朴に create() を書くと、完了するまで解決しない Promise を、進捗の表示が無いまま待つことになります。
availability() の戻り 画面に出すもの create() を呼ぶか
downloadable 開始ボタンと、数分かかる旨の一文 click ハンドラの中でのみ
downloading 進捗バー(前回の到達点から) 呼ぶ。monitor を付けて進捗だけ拾う
available 機能そのもの 呼ぶ。初期化は 1 秒未満
unavailable クラウド経路。ボタンは出さない 呼ばない
再訪と二重起動の両方を、create() を包む関数ひとつで吸収できます。
let inflight = null ; // 同時に 2 本走らせないための共有 Promise
const PROGRESS_KEY = 'nano:download-progress' ;
export function ensureSession ( onProgress ) {
if (inflight) return inflight; // 2 回目以降は同じ Promise を返します
inflight = ( async () => {
// 再訪時は前回の到達点から描き始めます(0% に巻き戻さない)
const saved = Number (localStorage. getItem ( PROGRESS_KEY ) || 0 );
if (saved > 0 ) onProgress (saved);
try {
return await LanguageModel. create ({
... OPTS ,
monitor ( m ) {
m. addEventListener ( 'downloadprogress' , ( e ) => {
const pct = Math. round (e.loaded * 100 ); // e.loaded は 0〜1 の割合です
localStorage. setItem ( PROGRESS_KEY , String (pct));
onProgress (pct);
});
},
});
} finally {
inflight = null ;
localStorage. removeItem ( PROGRESS_KEY );
}
})();
return inflight;
}
inflight を持たせる理由は、画面の都合で ensureSession() が複数箇所から呼ばれるからです。要約ボタンと入力補完の両方が同じセッションを欲しがる画面では、包まずに書くと create() が二重に走ります。
保存した割合は、完了時に必ず消します。残したままにすると、モデルが削除されて再ダウンロードが始まったとき、前回の 90% から描き始めてしまいます。
もうひとつ、進捗が 100% に届いてから create() の Promise が解決するまでに、手元では数秒の間がありました。展開の時間です。ここでバーを 100% のまま止めると固まったように見えますので、私は「準備しています」という表示へ切り替えるようにしています。
なお 'downloading' の状態から create() を呼ぶとき、私の手元ではユーザー操作なしで通りました。ただしこのあたりは実装の変更が入りやすい箇所です。ボタンを残しておけば、どちらに転んでも画面は壊れません。
同じオリジンを複数タブで開かれると、進捗はタブごとに別々に届きます。表示を揃えたいときは BroadcastChannel で割合を配ると、片方だけ止まって見える状態を避けられます。
22 GB の空きと、10 GB を切ったときに起きること
「オンデバイスだから制約が無い」わけではありません。Chrome の Built-in AI には、はっきりした端末要件があります。
項目 要件
OS Windows 10 / 11、macOS 13 以降、Linux、ChromeOS(Chromebook Plus)
ストレージ Chrome プロファイルのあるボリュームに 22 GB 以上 の空き
GPU VRAM 4 GB 超
CPU(GPU を使わない場合) RAM 16 GB 以上・4 コア以上
ネットワーク 初回ダウンロード時のみ必要(従量制回線は不可)
22 GB という数字は、モデル本体の大きさではありません。ダウンロードと展開のための余裕を含んだ要件です。
そして運用上、いちばん厄介なのが次の挙動です。ダウンロード後にストレージの空きが 10 GB を下回ると、モデルは端末から削除されます 。要件を再び満たせば自動で再ダウンロードされますが、その間、あなたのアプリのオンデバイス機能は静かに消えます。
これは「一度動いたら動き続ける」という前提が通用しないことを意味します。フォールバック経路は、初回だけのためではなく、いつでも戻ってくる状態のために用意しておく必要があります 。ユーザーの端末容量は、こちらから見えません。
現在のモデルサイズは chrome://on-device-internals で確認できます。挙動が怪しいときは、まずここを見るのが早道です。
ほかに、実装の前に把握しておきたい制約が2つあります。
Web Worker では使えません 。権限ポリシーの確認が絡むため、当面はトップレベルのウィンドウと同一オリジンの iframe のみです。重い推論をワーカーへ逃がす設計は、現時点では組めません。
クロスオリジンの iframe には権限の委譲が要ります 。<iframe src="..." allow="language-model"> のように明示します。
Android は Chrome の API では届きません — 入口は ML Kit GenAI
ここが、いちばん誤解されやすいところだと感じています。
Chrome の Built-in AI API は、Android 版 Chrome では動きません 。iOS 版でも同様です。対応しているのはデスクトップ(と Chromebook Plus)のみ。「スマートフォンで動く Gemini Nano」と「ブラウザで動く Gemini Nano」は、同じモデル名を共有しているだけで、開発者から見た入口はまったく別物です。
Android アプリから Gemini Nano を使う正規の入口は ML Kit の GenAI API です。AICore という Android のシステムサービスの上に乗っており、端末に1つある Gemini Nano を全アプリで共有します。モデルをアプリに同梱する必要はありません。
用意されているのは、要約・校正・書き換え・画像説明といったタスク特化の API と、自由なプロンプトを送れる GenAI Prompt API です。
// build.gradle.kts
// バージョンは公式ドキュメントで最新を確認してください
dependencies {
implementation ( "com.google.mlkit:genai-summarization:<latest>" )
}
そして実装の形は、Chrome とよく似ています。
val options = SummarizerOptions. builder (context)
. setInputType (InputType.ARTICLE)
. setOutputType (OutputType.ONE_BULLET)
. build ()
val summarizer = Summarization. getClient (options)
// 使う前に状態を確認する — Chrome の availability() と同じ役割です
when (summarizer. checkFeatureStatus (). await ()) {
FeatureStatus.UNAVAILABLE -> useCloudFallback ()
FeatureStatus.DOWNLOADABLE -> summarizer. downloadFeature (downloadCallback)
FeatureStatus.DOWNLOADING -> showProgress ()
FeatureStatus.AVAILABLE -> runSummarization (summarizer)
}
checkFeatureStatus() の4状態が、Chrome の availability() の4状態とほぼ対応していることに気づきます。プラットフォームは違っても、設計思想は同じ です。「モデルはあるかもしれないし、無いかもしれない。まず尋ねてから使う」。
だから、この記事の要点を1文にまとめるならこうなります。入口の名前を覚えることより、確認 → ダウンロード → 実行という3段を守ることのほうが、動作を分けます 。旧いサンプルが動かない本当の理由は、名前空間が変わったからではなく、この3段が省略されていたからです。名前空間の変更は、たまたま同時に目に入っただけでした。
対応端末にも幅があります。GenAI API が動くのは、MediaTek Dimensity・Qualcomm Snapdragon・Google Tensor といった対応チップセットを積んだ端末です。すべての Android 端末で使えるわけではないので、FeatureStatus.UNAVAILABLE は例外ではなく通常の分岐として扱ってください。詳しくは ML Kit GenAI API の概要 にまとまっています。
機能を出す前に、届く端末を数えておく
要件の一覧を読むと「思ったより厳しい」と感じます。では、実際に自分の読者の何割へ届くのか。これは推測しても当たりませんので、記録を先に仕込むことにしました。
resolveOnDeviceMode() はすでに 3 値へ畳んであります。返ってきた値をそのまま 1 回だけ送れば、到達段階の分布が取れます。
// 1 セッションにつき 1 回だけ、到達段階を記録します
export async function reportOnDeviceMode () {
if (sessionStorage. getItem ( 'nano:reported' )) return ;
const mode = await resolveOnDeviceMode (); // 'ready' | 'prompt-download' | 'cloud'
sessionStorage. setItem ( 'nano:reported' , mode);
navigator. sendBeacon (
'/api/telemetry/on-device' ,
new Blob (
[ JSON . stringify ({ mode, platform: navigator.userAgentData?.platform ?? 'unknown' })],
{ type: 'application/json' },
),
);
}
端末を識別できる値は載せません。段階と大まかなプラットフォームだけあれば、機能を出すかどうかの判断はつきます。sendBeacon にしているのは、離脱の直前に呼んでも落ちないためです。
私の手元にある端末で resolveOnDeviceMode() を踏ませた結果が、次の表です。母数は 5 台ですので統計ではありませんが、内訳の偏り方は掴めます。
端末・環境 戻り値 理由
macOS 14・M2・空き 180 GB・Chrome 148 ready モデル取得済み。要件を全て満たす
同じ Mac・空きを 12 GB まで減らした状態 cloud 22 GB 要件を割り、availability が unavailable
Windows 11・内蔵 GPU(VRAM 4 GB)・RAM 16 GB prompt-download 要件は満たすが未取得。ボタン待ち
Android・Chrome cloud LanguageModel がそもそも存在しない
iPadOS・Safari cloud 同上
5 台のうち、初回訪問でそのまま使えたのは 1 台だけでした。空きストレージを削った Mac が cloud に落ちたのが、いちばん実感の伴う結果です。要件を満たす端末であっても、その日の空き容量ひとつで経路が変わります。
ここから決めたことが 2 つあります。
ひとつ、主導線はクラウドに置く こと。オンデバイスは速さと機微の保護を上乗せする経路であって、機能の入口ではありません。「オンデバイスで処理しています」という表示を主役に据えると、cloud に落ちた端末で説明のつかない画面になります。
ふたつ、ダウンロードを促すボタンは、その機能で明確に得をする画面にだけ置く こと。数分と数 GB をお願いする以上、押した先の価値が見えていなければ、途中で閉じられます。入力補完のような目立たない機能のために、最初の訪問でダウンロードを頼むのは無理があります。
記録は機能を公開する前から始めておくと、判断が早くなります。分布を見てから UI を決めれば、誰も踏まない分岐を先に作らずに済みます。
Nano に渡す仕事と、クラウドに残す仕事
要件を満たして動き始めると、次は「何を任せるか」の判断になります。
Nano はモデルが小さい分、できることも絞られます。Prompt API はテキスト・画像・音声を入力に取れますが、出力はテキストのみ 。対応言語も現時点では en / ja / es / de / fr に限られます。
個人開発の範囲で試した限りですが、私の手元での線引きはこうなりました。
性質 Nano に渡す クラウド(Gemini API)に残す
応答速度 入力補完・返信候補など、待たせたくない処理 数秒待てるバッチ処理
データの機微 端末から出したくないメモ・チャットの分類 公開前提のコンテンツ生成
出力の長さ 短い要約・タグ付け・判定 長文の分析・レポート
正確さの要求 外しても実害の小さい候補提示 誤りが許されない抽出・整形
コスト 頻度が高く、単価を積みたくない処理 頻度が低く、品質が要る処理
判定や分類のように出力が短く、構造が決まっている処理 は Nano の得意分野です。しかも Prompt API には responseConstraint があり、JSON Schema を渡して出力を縛れます。
const session = await LanguageModel. create ();
const schema = { type: 'boolean' };
const result = await session. prompt (
`次の文章は問い合わせですか? \n\n ${ text }` ,
{ responseConstraint: schema }
);
console. log ( JSON . parse (result)); // true / false
分類器としての Nano は、素直に使えます。逆に、長文生成を任せると質でも速度でもクラウドに届きません。
もうひとつ、セッション設計で押さえておきたい点があります。会話を続けるとコンテキストは当然埋まっていきます。
console. log ( `${ session . contextUsage } / ${ session . contextWindow }` );
session. addEventListener ( 'contextoverflow' , () => {
// 古いやり取りから順に落とされ始めた合図です
});
溢れた場合、古いプロンプトと応答の組から順に削られます(システムプロンプトだけは残ります)。それでも足りなければ QuotaExceededError が飛びます。長い会話を扱うなら、contextUsage を見ながら自分で区切るほうが挙動を読めます。
なお temperature と topK の調整は、Chrome 拡張の Prompt API か、オリジントライアルを有効にした場合に限られます。通常の Web ページからは、現時点では効きません 。ここは私も一度勘違いして、効かないパラメータを渡し続けていました。
より踏み込んだ振り分けの設計は 0.5B のローカル LLM を「前段ルーター」として使うときの設計 と Gemini API と Apple FoundationModels のハイブリッド推論ルーティング実装メモ にまとめています。Chrome 拡張から使う場合の全体像は Manifest V3 サイドパネルに組む常駐 AI アシスタント が参考になるはずです。
画像を渡す、途中経過を見せる — マルチモーダル入力とストリーミング
Prompt API は画像と音声も入力に取れます。ただし宣言が必要です。expectedInputs に image を含めずに Blob を渡すと、実行時に NotSupportedError で弾かれます。
const session = await LanguageModel. create ({
expectedInputs: [
{ type: 'text' , languages: [ 'ja' ] },
{ type: 'image' }, // 宣言を忘れると実行時に落ちます
],
expectedOutputs: [{ type: 'text' , languages: [ 'ja' ] }],
});
const blob = await fetch ( '/receipt.png' ). then (( r ) => r. blob ());
const store = await session. prompt ([
{
role: 'user' ,
content: [
{ type: 'text' , value: 'この画像に写っている店名だけを返してください。' },
{ type: 'image' , value: blob },
],
},
]);
テキストだけの呼び出しと違い、content が配列になります。ここを文字列のまま渡してしまい、画像が黙って無視される、という取り違えを私も一度やりました。エラーが出ないぶん、気づくまでに時間がかかります。
出力が伸びる処理では promptStreaming() に切り替えます。要約の3秒は、黙って待たせると体感でもっと長く感じます。
const stream = session. promptStreaming ( `次の記事を3行で要約してください。 \n\n ${ text }` );
for await ( const chunk of stream) {
output. append (chunk); // 届くのは差分です。全文ではありません
}
チャンクは差分で届きます。旧仕様では毎回それまでの全文が渡っていたため、連結処理を残したまま移行して文章が二重になる、という実装を見かけます。移行時はここを確認してください。
手元で計った限りでは、3行要約の最初の文字が出るまでが 0.4 秒ほどでした。完了までの時間は変わらなくても、待たされている感覚はほとんど残りません。短い判定は prompt()、読ませる出力は promptStreaming()。この使い分けだけは、実装の初期に決めておくことをお勧めします。
手元の1台で測った、ダウンロードと推論の実時間
数字がないと判断できないので、個人開発の範囲で手元の1台だけで測った参考値を残しておきます。厳密なベンチマークではなく、あくまで「桁感」をつかむためのものです(macOS 14・M2・VRAM 共有・Chrome 148)。
局面 手元での実測(参考値)
初回モデルダウンロード 約3〜6分(回線と時間帯で振れます)
create() の初期化(モデル取得済み) 0.3〜0.8 秒
短い分類プロンプト(真偽1つ) 120〜300 ミリ秒
3行要約(800字入力) 1.5〜3 秒
同じ分類を Gemini Flash(クラウド往復) 400〜900 ミリ秒
読み取れたのは2点です。ひとつ、短い判定なら Nano のほうがクラウド往復より速い こと。ネットワークの往復が消える分、短い判定では体感でおよそ2倍速く、待たされる感覚が消えます。もうひとつ、長文になると差が縮み、やがて逆転する こと。要約のように出力が伸びる処理では、クラウドの大きなモデルのほうが速く、質も上でした。
だから速度で選ぶ場合は、対象を「短くて頻度の高い処理」に絞ることをお勧めします。ここを外して長文生成をオンデバイスに寄せると、速さのためのはずが、遅さを抱え込みます。
初期化コストも無視できません。create() はセッションごとに走るので、分類のたびに作り直すと初期化ぶんが積み上がります。使い回せるセッションを1つ保持して、prompt() だけを繰り返すほうが安定しました。
例外を静かに握りつぶさない — 3つのエラーを分岐にする
動く経路だけを書くと、本番運用でいちばん困るのは「昨日まで動いていたのに、今日は無言で何も起きない」状態です。オンデバイス AI では、これが日常的に起こり得ます。ストレージが 10 GB を切ればモデルは消え、コンテキストが溢れれば例外が飛びます。
そこで、prompt() の呼び出しは3つのエラーを見分けられる形にしておくと、崩れ方が読めるようになります。
async function runOnDevice ( session , text ) {
try {
return await session. prompt (text);
} catch (err) {
// 1. コンテキスト超過 — セッションを畳んで作り直す
if (err.name === 'QuotaExceededError' ) {
session. destroy ();
return { retry: true , reason: 'context-full' };
}
// 2. モデルが実行時に消えていた — 状態を採り直す
if (err.name === 'NotSupportedError' || err.name === 'InvalidStateError' ) {
const state = await LanguageModel. availability ();
if (state !== 'available' ) return fallbackToCloud (text);
}
// 3. それ以外は握りつぶさず、クラウドへ退避する
return fallbackToCloud (text);
}
}
肝は、どのエラーでも最後はクラウドへ戻れること です。オンデバイスは速さと機微の保護のための最適化であって、機能の唯一の入口にしてはいけません。入口を1つに絞ると、モデルが消えた瞬間にアプリの機能そのものが消えます。
そして、モデルが消える瞬間は事前に検知できません。だからこそ、availability() を初回だけでなく、失敗したときにもう一度呼ぶ 設計が効きます。「使う前に尋ねる」を、「落ちたらもう一度尋ねる」まで広げておく。この一手間が、静かな不具合の対処を、目に見える分岐へと変えます。
次の一歩
もし手元のコードが動いていないなら、確認する順番はひとつです。
chrome://on-device-internals を開き、モデルが入っているかを見る
入っていなければ、availability() の戻り値をそのまま console.log する
'downloadable' が返るなら、create() をボタンの click ハンドラの中へ移す
ai. で始まるコードを探して置き換えるのは、そのあとで構いません。名前空間は目に見える違いなので気づきますが、availability() の欠落は何も言わずに失敗します。
そして、ひとつだけ設計に足しておきたいものがあります。オンデバイス機能が使えない端末で、アプリが何を表示するか 。これを先に決めてから実装に入ると、22 GB の要件も、10 GB を切ったときのモデル削除も、想定内の分岐として収まります。