個人開発のブラウザ時間を、少しだけ賢くする
個人開発でブラウザを開いている時間は、思っている以上に長いものです。記事を読み、コードを追い、海外のドキュメントを行き来する——その合間に「ここだけ要約してほしい」「このコードの意図を一言で知りたい」と感じる瞬間が、私自身、一日に何度も訪れます。
そのたびに別タブの AI チャットへ貼り付けるのは、地味に集中を削ります。ならばブラウザの中に AI を住まわせてしまおう、と考えて、Gemini API と Chrome Extension でサイドパネル型のアシスタントを組みました。その設計と実装を、つまずいた箇所も含めて、手元のコードのまままとめております。
対象読者は JavaScript の基礎を押さえていて、Chrome Extension の開発に初めて挑む方、あるいは既存の拡張機能に AI 機能を足したい方を想定しております。
Chrome Extension のアーキテクチャ
Chrome Extension は大きく分けて3つのレイヤーで構成されます。Gemini API と連携する AI アシスタントでは、それぞれのレイヤーが以下の役割を果たします。
Service Worker(バックグラウンドスクリプト) : Manifest V3 では従来の Background Pages に代わり、Service Worker がバックグラウンド処理を担当します。Gemini API への HTTP リクエストはここで実行し、API キーがコンテンツスクリプトに露出しないように保護します。
Content Script : 閲覧中の Web ページの DOM にアクセスし、テキストの抽出やハイライト表示を行います。ページのコンテンツを取得してバックグラウンドに送信する中継役です。
Side Panel UI : Chrome 114 以降で利用可能なサイドパネルに、チャットインターフェースや結果の表示領域を配置します。ユーザーとの対話はここで完結します。
この3層構造により、API キーの安全な管理、ページコンテンツへのアクセス、直感的な UI の提供をそれぞれ分離して実現できます。
Gemini API キーの取得と準備
まず Google AI Studio から API キーを取得します。
Google AI Studio にアクセスし、Google アカウントでログイン
左メニューから「Get API Key」を選択
「Create API Key」をクリックしてキーを生成
生成されたキーを安全な場所に保管
Gemini API の呼び出しには gemini-2.5-flash モデルを使用します。無料枠でも個人利用の拡張機能には十分な容量がありますが、1分あたり・1日あたりのリクエスト上限は時期によって見直されます。最新の値は Google AI Studio のレート上限表示で確認し、上限が下がっても破綻しない設計(後述のバックオフとトークン節約)を前提にしておくと安心です。
プロジェクト構成と Manifest V3 の設定
以下のディレクトリ構成でプロジェクトを作成します。
gemini-assistant/
├── manifest.json
├── background.js
├── content.js
├── sidepanel/
│ ├── index.html
│ ├── style.css
│ └── app.js
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
manifest.json の設定は次のとおりです。
{
"manifest_version" : 3 ,
"name" : "Gemini AI Assistant" ,
"version" : "1.0.0" ,
"description" : "Gemini API を活用したブラウザ内蔵 AI アシスタント" ,
"permissions" : [
"sidePanel" ,
"activeTab" ,
"storage" ,
"contextMenus"
],
"background" : {
"service_worker" : "background.js"
},
"side_panel" : {
"default_path" : "sidepanel/index.html"
},
"content_scripts" : [
{
"matches" : [ "<all_urls>" ],
"js" : [ "content.js" ]
}
],
"icons" : {
"16" : "icons/icon16.png" ,
"48" : "icons/icon48.png" ,
"128" : "icons/icon128.png"
}
}
permissions の各項目について補足します。sidePanel はサイドパネル UI の表示に必須です。activeTab は現在のタブのコンテンツにアクセスするために使います。storage は API キーや会話履歴の保存に使用し、contextMenus は右クリックメニューからの呼び出しに対応します。
Service Worker での Gemini API 連携
バックグラウンドの Service Worker で Gemini API とのやり取りを実装します。API キーは chrome.storage.local に安全に保存し、外部に漏れないようにします。
// background.js
const GEMINI_API_URL =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" ;
// API キーを安全に取得
async function getApiKey () {
const result = await chrome.storage.local. get ( "geminiApiKey" );
return result.geminiApiKey;
}
// Gemini API にリクエストを送信
async function callGeminiApi ( prompt , systemInstruction ) {
const apiKey = await getApiKey ();
if ( ! apiKey) {
throw new Error ( "API キーが設定されていません" );
}
const requestBody = {
system_instruction: {
parts: [{ text: systemInstruction }]
},
contents: [
{
parts: [{ text: prompt }]
}
],
generationConfig: {
temperature: 0.7 ,
maxOutputTokens: 2048
}
};
const response = await fetch ( `${ GEMINI_API_URL }?key=${ apiKey }` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify (requestBody)
});
if ( ! response.ok) {
const error = await response. json ();
throw new Error (
`API エラー: ${ error . error ?. message || response . statusText }`
);
}
const data = await response. json ();
return data.candidates[ 0 ].content.parts[ 0 ].text;
}
// メッセージリスナーの設定
chrome.runtime.onMessage. addListener (( request , sender , sendResponse ) => {
if (request.action === "summarize" ) {
callGeminiApi (
request.content,
"以下のWebページの内容を日本語で簡潔に要約してください。" +
"重要なポイントを箇条書きで整理し、最後に一言でまとめてください。"
)
. then (( result ) => sendResponse ({ success: true , data: result }))
. catch (( err ) => sendResponse ({ success: false , error: err.message }));
return true ; // 非同期レスポンスを有効化
}
if (request.action === "translate" ) {
callGeminiApi (
request.content,
`以下のテキストを${ request . targetLang }に翻訳してください。` +
"自然で読みやすい訳文にしてください。"
)
. then (( result ) => sendResponse ({ success: true , data: result }))
. catch (( err ) => sendResponse ({ success: false , error: err.message }));
return true ;
}
if (request.action === "analyzeCode" ) {
callGeminiApi (
request.content,
"以下のコードを分析してください。" +
"処理内容の説明、改善点の提案、潜在的なバグがあればそれも指摘してください。"
)
. then (( result ) => sendResponse ({ success: true , data: result }))
. catch (( err ) => sendResponse ({ success: false , error: err.message }));
return true ;
}
});
// 右クリックメニューの追加
chrome.runtime.onInstalled. addListener (() => {
chrome.contextMenus. create ({
id: "gemini-summarize" ,
title: "Gemini で要約する" ,
contexts: [ "page" ]
});
chrome.contextMenus. create ({
id: "gemini-analyze-selection" ,
title: "Gemini で選択テキストを分析" ,
contexts: [ "selection" ]
});
});
chrome.contextMenus.onClicked. addListener ( async ( info , tab ) => {
if (info.menuItemId === "gemini-summarize" ) {
chrome.tabs. sendMessage (tab.id, { action: "getPageContent" });
}
if (info.menuItemId === "gemini-analyze-selection" ) {
const result = await callGeminiApi (
info.selectionText,
"選択されたテキストを分析し、要点を整理してください。"
);
chrome.runtime. sendMessage ({
action: "showResult" ,
data: result
});
}
});
chrome.runtime.onMessage.addListener のコールバックで return true を返す点が肝心です。これにより非同期処理の完了を待ってからレスポンスを送信できます。
Content Script でページ情報を取得
Content Script は閲覧中のページからテキストを抽出し、Service Worker に渡す役割を担います。
// content.js
// ページの主要テキストを抽出する
function extractPageContent () {
// article タグがあればその中身を優先
const article = document. querySelector ( "article" );
if (article) {
return article.innerText. trim ();
}
// main タグにフォールバック
const main = document. querySelector ( "main" );
if (main) {
return main.innerText. trim ();
}
// 最終手段として body 全体
const body = document.body.innerText;
// 長すぎる場合は先頭 8,000 文字に制限
return body. substring ( 0 , 8000 ). trim ();
}
// コードブロックを検出して抽出
function extractCodeBlocks () {
const codeElements = document. querySelectorAll ( "pre code, .highlight code" );
return Array. from (codeElements). map (( el ) => ({
language: el.className. replace ( "language-" , "" ),
code: el.textContent. trim ()
}));
}
// バックグラウンドからのメッセージを受信
chrome.runtime.onMessage. addListener (( request , sender , sendResponse ) => {
if (request.action === "getPageContent" ) {
const content = extractPageContent ();
const title = document.title;
const url = window.location.href;
// サイドパネルに送信
chrome.runtime. sendMessage ({
action: "summarize" ,
content: `タイトル: ${ title } \n URL: ${ url } \n\n ${ content }`
});
}
if (request.action === "getSelectedText" ) {
const selection = window. getSelection (). toString ();
sendResponse ({ text: selection });
}
if (request.action === "getCodeBlocks" ) {
const codeBlocks = extractCodeBlocks ();
sendResponse ({ codeBlocks });
}
});
extractPageContent() ではまず <article> タグの中身を探し、なければ <main> タグ、さらになければ <body> 全体を使います。この優先順位により、広告やナビゲーションなどのノイズを除外して本文だけを効率的に取得できます。
サイドパネル UI の構築
サイドパネルの HTML と JavaScript で、ユーザーと AI のインタラクションを実現します。
<!-- sidepanel/index.html -->
<! DOCTYPE html >
< html lang = "ja" >
< head >
< meta charset = "UTF-8" />
< title >Gemini AI Assistant</ title >
< link rel = "stylesheet" href = "style.css" />
</ head >
< body >
< div class = "container" >
< header >
< h1 >Gemini AI Assistant</ h1 >
< button id = "settings-btn" title = "設定" >⚙️</ button >
</ header >
< div id = "settings-panel" class = "hidden" >
< label for = "api-key-input" >API キー:</ label >
< input type = "password" id = "api-key-input"
placeholder = "Gemini API キーを入力" />
< button id = "save-key-btn" >保存</ button >
</ div >
< div class = "actions" >
< button id = "summarize-btn" class = "action-btn" >
📝 ページ要約
</ button >
< button id = "translate-btn" class = "action-btn" >
🌐 翻訳
</ button >
< button id = "code-btn" class = "action-btn" >
💻 コード解析
</ button >
</ div >
< div id = "chat-area" >
< div id = "messages" ></ div >
</ div >
< div class = "input-area" >
< textarea id = "user-input"
placeholder = "質問を入力..."
rows = "3" ></ textarea >
< button id = "send-btn" >送信</ button >
</ div >
</ div >
< script src = "app.js" ></ script >
</ body >
</ html >
// sidepanel/app.js
const messagesEl = document. getElementById ( "messages" );
const userInput = document. getElementById ( "user-input" );
const sendBtn = document. getElementById ( "send-btn" );
const summarizeBtn = document. getElementById ( "summarize-btn" );
const translateBtn = document. getElementById ( "translate-btn" );
const codeBtn = document. getElementById ( "code-btn" );
const settingsBtn = document. getElementById ( "settings-btn" );
const settingsPanel = document. getElementById ( "settings-panel" );
const saveKeyBtn = document. getElementById ( "save-key-btn" );
const apiKeyInput = document. getElementById ( "api-key-input" );
// メッセージを表示エリアに追加
function addMessage ( text , type = "assistant" ) {
const div = document. createElement ( "div" );
div.className = `message ${ type }` ;
div.textContent = text;
messagesEl. appendChild (div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ローディング表示
function showLoading () {
const div = document. createElement ( "div" );
div.className = "message assistant loading" ;
div.id = "loading-indicator" ;
div.textContent = "考え中..." ;
messagesEl. appendChild (div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function hideLoading () {
const el = document. getElementById ( "loading-indicator" );
if (el) el. remove ();
}
// 設定パネルの表示切り替え
settingsBtn. addEventListener ( "click" , () => {
settingsPanel.classList. toggle ( "hidden" );
});
// API キーの保存
saveKeyBtn. addEventListener ( "click" , async () => {
const key = apiKeyInput.value. trim ();
if (key) {
await chrome.storage.local. set ({ geminiApiKey: key });
apiKeyInput.value = "" ;
settingsPanel.classList. add ( "hidden" );
addMessage ( "API キーを保存しました。" , "system" );
}
});
// 自由入力の送信
sendBtn. addEventListener ( "click" , async () => {
const text = userInput.value. trim ();
if ( ! text) return ;
addMessage (text, "user" );
userInput.value = "" ;
showLoading ();
chrome.runtime. sendMessage (
{ action: "summarize" , content: text },
( response ) => {
hideLoading ();
if (response?.success) {
addMessage (response.data);
} else {
addMessage (
`エラー: ${ response ?. error || "不明なエラー"}` , "error"
);
}
}
);
});
// ページ要約ボタン
summarizeBtn. addEventListener ( "click" , async () => {
addMessage ( "ページを要約しています..." , "system" );
showLoading ();
const [ tab ] = await chrome.tabs. query ({
active: true , currentWindow: true
});
chrome.tabs. sendMessage (
tab.id,
{ action: "getPageContent" },
( response ) => {
// Content Script が getPageContent を処理
}
);
});
// バックグラウンドからの結果を受信
chrome.runtime.onMessage. addListener (( request ) => {
if (request.action === "showResult" ) {
hideLoading ();
addMessage (request.data);
}
});
// Enter キーで送信(Shift+Enter で改行)
userInput. addEventListener ( "keydown" , ( e ) => {
if (e.key === "Enter" && ! e.shiftKey) {
e. preventDefault ();
sendBtn. click ();
}
});
このコードでは chrome.runtime.sendMessage と chrome.tabs.sendMessage を使い分けている点に注意してください。前者はバックグラウンド(Service Worker)への通信、後者はコンテンツスクリプトへの通信です。
ページ要約機能の実装詳細
ページ要約は拡張機能の中核となる機能です。ここでは Gemini API の System Instruction を活用し、一貫性のある高品質な要約を生成します。
// background.js に追加するシステムプロンプト定義
const SYSTEM_PROMPTS = {
summarize: `あなたは優秀なリサーチアシスタントです。
Webページの内容を以下のフォーマットで日本語で要約してください。
【概要】1〜2文で全体をまとめる
【主要ポイント】3〜5個の箇条書き
【結論・示唆】読者にとっての意味を1文で
専門用語が含まれる場合は簡潔な補足を添えてください。` ,
translate: `あなたは経験豊富な翻訳者です。
原文のニュアンスを保ちつつ、対象言語として自然な訳文を作成してください。
翻訳結果のみを出力し、説明は不要です。` ,
analyzeCode: `あなたはシニアソフトウェアエンジニアです。
コードを分析し以下を回答してください。
【処理内容】このコードが何をしているかの概要
【注目すべき点】設計上の良い点やパターン
【改善提案】パフォーマンスや可読性の観点からの提案
【注意点】潜在的なバグやセキュリティ上の懸念`
};
API のレスポンスが長くなりすぎないよう maxOutputTokens を 2048 に設定していますが、技術ドキュメントの要約など詳細な出力が必要な場合は 4096 に増やすことも検討してください。
翻訳機能とコード解析機能の追加
翻訳機能では、ページ全体または選択テキストを対象として、ドロップダウンで選んだ言語に翻訳します。コード解析はページ内の <pre><code> ブロックを自動検出し、Gemini に送信します。
// sidepanel/app.js に追加する翻訳機能
translateBtn. addEventListener ( "click" , async () => {
const [ tab ] = await chrome.tabs. query ({
active: true , currentWindow: true
});
chrome.tabs. sendMessage (
tab.id,
{ action: "getSelectedText" },
( response ) => {
const text = response?.text;
if ( ! text) {
addMessage (
"翻訳するテキストを選択してください。" , "system"
);
return ;
}
addMessage ( `翻訳対象: ${ text . substring ( 0 , 100 ) }...` , "user" );
showLoading ();
chrome.runtime. sendMessage (
{
action: "translate" ,
content: text,
targetLang: "日本語"
},
( res ) => {
hideLoading ();
if (res?.success) {
addMessage (res.data);
} else {
addMessage ( `エラー: ${ res ?. error }` , "error" );
}
}
);
}
);
});
// コード解析機能
codeBtn. addEventListener ( "click" , async () => {
const [ tab ] = await chrome.tabs. query ({
active: true , currentWindow: true
});
chrome.tabs. sendMessage (
tab.id,
{ action: "getCodeBlocks" },
( response ) => {
const blocks = response?.codeBlocks;
if ( ! blocks || blocks. length === 0 ) {
addMessage (
"このページにコードブロックが見つかりませんでした。" ,
"system"
);
return ;
}
// 最初のコードブロックを解析
const target = blocks[ 0 ];
addMessage (
`${ target . language || "不明"} のコードを解析中...` ,
"system"
);
showLoading ();
chrome.runtime. sendMessage (
{ action: "analyzeCode" , content: target.code },
( res ) => {
hideLoading ();
if (res?.success) {
addMessage (res.data);
} else {
addMessage ( `エラー: ${ res ?. error }` , "error" );
}
}
);
}
);
});
期待される出力例として、GitHub のコードページでコード解析ボタンを押した場合、Gemini は以下のような回答を返します。
【処理内容】
Express.js を使った REST API サーバーで、ユーザー認証と
CRUD操作を提供しています。
【注目すべき点】
ミドルウェアパターンを適切に使用し、認証ロジックが
分離されています。
【改善提案】
エラーハンドリングを集約するミドルウェアを追加すると
コードの重複が減ります。
【注意点】
パスワードのハッシュ化に bcrypt のラウンド数が 8 と
低めに設定されています。本番環境では 12 以上を推奨します。
レスポンスをストリーミングで段階的に表示する
要約や翻訳は、出力が全部そろうまで待つと数秒の沈黙が生まれます。この沈黙が、体感の遅さの正体です。generateContent ではなく streamGenerateContent を使い、届いた断片から順に描画すると、最初の一文字までの時間が短くなり、待たされている感覚がかなり和らぎます。
ストリーミングで気をつけたいのは、Service Worker からサイドパネルへ「少しずつ」渡す経路です。sendResponse は一度しか返せないため、逐次更新には向きません。そこで chrome.runtime.connect による長寿命のポートを使います。
// background.js — ストリーミング応答
const STREAM_URL =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent" ;
async function streamGemini ( prompt , systemInstruction , onChunk ) {
const apiKey = await getApiKey ();
if ( ! apiKey) throw new Error ( "API キーが設定されていません" );
const res = await fetch ( `${ STREAM_URL }?alt=sse&key=${ apiKey }` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({
system_instruction: { parts: [{ text: systemInstruction }] },
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { temperature: 0.7 , maxOutputTokens: 2048 }
})
});
if ( ! res.ok) throw new Error ( `API エラー: ${ res . status }` );
const reader = res.body. getReader ();
const decoder = new TextDecoder ();
let buffer = "" ;
while ( true ) {
const { value , done } = await reader. read ();
if (done) break ;
buffer += decoder. decode (value, { stream: true });
// SSE は "data: {...}" 行で届く。未完了の末尾行は次回へ繰り越す
const lines = buffer. split ( " \n " );
buffer = lines. pop ();
for ( const line of lines) {
if ( ! line. startsWith ( "data:" )) continue ;
const json = line. slice ( 5 ). trim ();
if ( ! json) continue ;
try {
const parsed = JSON . parse (json);
const text = parsed.candidates?.[ 0 ]?.content?.parts?.[ 0 ]?.text;
if (text) onChunk (text);
} catch {
// 行をまたいで分割された JSON は次のチャンクで補完される
}
}
}
}
肝心なのは buffer = lines.pop() の一行です。SSE の data: 行はチャンクの切れ目で途中分割されて届くことがあり、最後の不完全な行を次回へ持ち越さないと JSON パースが頻繁に失敗します。私自身、この一行を入れるまで「たまに後半が欠ける」現象に悩まされました。
次に、ポートでストリームを中継します。
// background.js — ポートで逐次中継
chrome.runtime.onConnect. addListener (( port ) => {
if (port.name !== "gemini-stream" ) return ;
port.onMessage. addListener ( async ( msg ) => {
try {
await streamGemini (msg.content, SYSTEM_PROMPTS [msg.mode], ( chunk ) => {
port. postMessage ({ type: "chunk" , text: chunk });
});
port. postMessage ({ type: "done" });
} catch (err) {
port. postMessage ({ type: "error" , error: err.message });
}
});
});
サイドパネル側は、受け取った断片をそのまま吹き出しへ足していきます。先ほどの addMessage は、追記できるよう生成した要素を返すように一行だけ変更しておきます(return div; を末尾に足します)。
// sidepanel/app.js — ストリームを逐次描画
function askStreaming ( content , mode ) {
const port = chrome.runtime. connect ({ name: "gemini-stream" });
const bubble = addMessage ( "" , "assistant" );
port. postMessage ({ content, mode });
port.onMessage. addListener (( msg ) => {
if (msg.type === "chunk" ) {
bubble.textContent += msg.text;
messagesEl.scrollTop = messagesEl.scrollHeight;
} else if (msg.type === "done" ) {
port. disconnect ();
} else if (msg.type === "error" ) {
bubble.textContent = `エラー: ${ msg . error }` ;
bubble.classList. add ( "error" );
port. disconnect ();
}
});
}
これで、要約ボタンや翻訳ボタンから askStreaming(content, "summarize") を呼べば、生成の進行がそのまま画面に流れます。
会話履歴を保持してコンテキストを途切れさせない
単発の要約だけなら履歴は不要ですが、「さっきの要約をもっと短く」「その3点目を詳しく」といった追い質問に応えるには、直前までのやり取りを contents に含める必要があります。履歴は chrome.storage に持たせます。
// background.js — 会話履歴つきリクエスト
async function loadHistory ( sessionId ) {
const key = `history:${ sessionId }` ;
const r = await chrome.storage.session. get (key);
return r[key] || [];
}
async function saveTurn ( sessionId , role , text ) {
const key = `history:${ sessionId }` ;
const history = await loadHistory (sessionId);
history. push ({ role, parts: [{ text }] });
// 直近 20 ターンに制限し、入力トークンの肥大を防ぐ
const trimmed = history. slice ( - 20 );
await chrome.storage.session. set ({ [key]: trimmed });
return trimmed;
}
async function chatWithHistory ( sessionId , userText ) {
const history = await saveTurn (sessionId, "user" , userText);
const apiKey = await getApiKey ();
const res = await fetch ( `${ GEMINI_API_URL }?key=${ apiKey }` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({ contents: history })
});
const data = await res. json ();
const reply = data.candidates[ 0 ].content.parts[ 0 ].text;
await saveTurn (sessionId, "model" , reply);
return reply;
}
ここで chrome.storage.session を選んでいるのには理由があります。session はブラウザを閉じると消えるメモリ上の領域で、local はディスクに永続化されます。会話には閲覧中ページの中身という機微な情報が混ざりやすいので、既定は session、ユーザーが明示的に「保存」したものだけ local へ、という分け方を私は推奨します。
直近20ターンで切っているのも実用上の判断です。古い文脈まで毎回送ると入力トークンが膨らみ、応答も遅く・高くなります。要約アシスタント程度の用途であれば、20ターンも残せば体感の連続性は十分でした。
トークンとレート制限を運用で抑える
無料枠で気持ちよく使い続けるには、1リクエストあたりの入力を小さく保つのが効きます。extractPageContent() は本文を8,000字で切っていますが、要約用途ならさらに前処理で削れます。見出しと各段落の先頭だけを残す素朴な前処理を入れたところ、長文記事で入力トークンを約40%削減できました(Dolice で運営している技術ブログ記事10本での実測値です)。
// content.js — 要約向けの軽量抽出
function extractForSummary () {
const root =
document. querySelector ( "article" ) ||
document. querySelector ( "main" ) ||
document.body;
const parts = [];
root. querySelectorAll ( "h1, h2, h3, p" ). forEach (( el ) => {
const t = el.innerText. trim ();
if ( ! t) return ;
// 段落は先頭2文だけ残す。見出しはそのまま
parts. push ( / ^ H [1-3] $ / . test (el.tagName)
? t
: t. split ( "。" ). slice ( 0 , 2 ). join ( "。" ));
});
return parts. join ( " \n " ). slice ( 0 , 6000 );
}
レート上限への対処は、429 を前提にした指数バックオフが定石です。短い拡張機能でも、入れておくと「連打したら無反応になる」事故を防げます。
// background.js — 指数バックオフ
async function withBackoff ( fn , max = 4 ) {
for ( let i = 0 ; i < max; i ++ ) {
try {
return await fn ();
} catch (err) {
const transient = /429 | 503/ . test ( String (err.message));
if ( ! transient || i === max - 1 ) throw err;
const wait = Math. min ( 2 ** i * 1000 , 8000 ); // 1s, 2s, 4s, 8s
await new Promise (( r ) => setTimeout (r, wait));
}
}
}
callGeminiApi をこの withBackoff でくるむだけで、一時的な 429 や 503 を黙って吸収できます。恒久的なエラー(401 や 400)は即座に投げ直すので、無駄な待ちも発生しません。
個人開発で運用してわかったこと
ここからは、自分の端末で日常的に使いながら気づいた、ドキュメントには明示されていない点をいくつか挙げておきます。
Service Worker は数十秒のアイドルで停止します。長い要約の途中で接続が切れたように見える現象に、最初は戸惑いました。ストリーミングのポートを張っている間は生き続けますが、保険として処理は冪等に——途中で切れても、もう一度押せば同じ結果に収束する作りにしておくと安心です。
API キーを chrome.storage.local へ置くのは、あくまでコンテンツスクリプトから隔離するための最低ラインです。共有端末では十分とは言えません。私はパスワードマネージャ前提の個人端末でだけ使う、という運用に割り切っています。
<all_urls> の常時注入は、重いページで初期描画にわずかな影響が出ます。実害が小さいうちは気になりませんが、必要なときだけ chrome.scripting.executeScript で動的注入するほうが軽い、というのが個人開発で回してみた実感です。最初から全ページへ常駐させる必要は、案外ありません。
デバッグとテスト
Chrome Extension のデバッグには、いくつかのポイントがあります。
拡張機能の読み込み : chrome://extensions にアクセスし、「デベロッパーモード」を有効にしたうえで「パッケージ化されていない拡張機能を読み込む」からプロジェクトフォルダを選択します。
Service Worker のデバッグ : 拡張機能の詳細ページにある「Service Worker」リンクをクリックすると、DevTools が開きます。console.log やネットワークタブで API リクエストの内容を確認できます。
Content Script のデバッグ : 通常のページで DevTools を開き、Console パネルのドロップダウンからコンテンツスクリプトのコンテキストを選択します。
よくあるエラーの対処法 :
Uncaught (in promise) Error: Could not establish connection — Content Script がまだ注入されていないページ(chrome:// で始まる URL など)でメッセージを送信すると発生します。送信前にタブの URL をチェックしましょう
net::ERR_INSUFFICIENT_RESOURCES — Service Worker がアイドル状態で停止した後に API リクエストが失敗する場合があります。chrome.alarms で定期的に wake-up させる方法が有効です
Gemini API の基本的な使い方に不安がある方は、Gemini API クイックスタートガイド で事前に基礎を固めることをおすすめします。また、TypeScript で型安全に開発したい場合は Gemini API TypeScript 完全入門ガイド も参考になります。
より実践的なエラーハンドリングやレート制限の対応について深く学びたい方には、Gemini API エラーハンドリング&レート制限 完全攻略 で本番環境に対応するための設計パターンを詳しく解説しています。
ここから先の拡張
ブラウザ常駐の AI アシスタントは、土台さえ組めれば伸ばし方は自由です。次の一歩としては、まず会話履歴を実際の追い質問へつなぎ込み、複数ページをまたいだやり取りに耐えるようにするのがおすすめです。そこまで動けば、音声入力や、選択範囲への右クリック解析の拡充も、同じ部品の組み合わせで足せます。
まずは chrome://extensions から読み込み、ページ要約をストリーミングで動かしてみてください。最初の一文字が返ってきた瞬間に、いつものブラウザが少しだけ賢くなった手応えがあるはずです。