3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【爆速】右クリックでAI解説!Chrome標準機能(LanguageModel)だけで作る完全オフライン拡張機能

Last updated at Posted at 2025-12-27

nsMQTSgGZypXKCTQ2CPF1766845613-1766845672.gif

🚀 はじめに

「Chrome標準でローカルLLMが動く! window.ai すごい!」

そんな記事を見てワクワクしながら試したのに、コンソールに無慈悲に表示される undefined ...。
ネット上の記事通りに設定しても全く動かず、週末を溶かしたMacユーザーは私だけではないはずです。

結論から言います。その記事、情報が古いです。

Chrome Canaryの最新ビルド(v145以降)では、これまでの定説だったAPIが大きく変更されています。
この記事では、最新環境で私が踏み抜いた「Mac特有の罠」と「隠された新API」、そしてそれを使って「爆速で動くAI解説拡張機能」を作る方法を共有します。

この記事で得られる知見

  1. Mac (Apple Silicon) でGemini Nanoを確実に有効化する設定(WebNN CoreMLの罠)
  2. window.ai が動かない理由と、新API LanguageModel の使い方
  3. 完全オフライン・無料・爆速で動くChrome拡張機能の実装コード

🛑 背景・モチベーション

ブラウザだけで、しかもオフラインでLLMが動く「Built-in AI」は、プライバシーやレスポンス速度の観点で革命的です。
しかし、Chrome Canaryの更新頻度は凄まじく、1ヶ月前の技術記事ですら役に立たないことが多々あります。

特に今回は、「MacBook Pro (Mシリーズ) × Chrome Canary v145」 という環境で検証を行ったところ、既存情報の通りにやっても全く動かないという壁にぶち当たりました。
意地になって調査した結果、APIの仕様自体が変わっていることを突き止めたのでログを残します。


🐣 準備:まずは「Chrome Canary」を手に入れよう

今回の実装には、通常のChromeではなく「Google Chrome Canary(カナリアビルド)」が必須です。

「Canaryって何? 開発者用?」と身構える必要はありません。

  • 特徴: 通常版より先の未来の機能を実験できる「最先端ビルド」。
  • 共存: 通常のChromeとは別アプリとしてインストールされます。普段使いのChrome環境はそのまま残るので安心してください。
  • アイコン: 黄金色(黄色)のアイコンが目印です。

まだ持っていない方は、以下からMac版をダウンロード・インストールしてください。

👉 Google Chrome Canary 公式ダウンロードページ

インストールが完了したら、黄色いアイコンのChromeを立ち上げて設定に進みましょう。


📚 本記事が扱う技術の公式情報

この記事の内容は、GoogleおよびWeb標準化団体(WICG)で議論されている以下の仕様に基づいています。
window.aiLanguageModel の仕様が頻繁に変わるのは、まさに現在進行形で標準化が進んでいるためです。

  • Google 公式ドキュメント (Built-in AI)
  • API仕様書 (GitHub Explainer)
    • explainers-by-googlers/prompt-api
    • エンジニアならここを見るべきです。APIの設計思想や、将来的な window.ai のあるべき姿(Draft)が議論されています。

💻 検証環境

以下の環境で動作確認済みです。これより古いバージョンだと挙動が異なる可能性があります。

項目 内容 備考
OS macOS 15.3.1 Apple Silicon (M4)
Browser Google Chrome Canary Ver 145.0.7590.0 以降
Model Gemini Nano 標準搭載モデル

🛠️ ハマりポイントと解決策(ここが本題)

私が直面した「3つの壁」と、その解決策です。

壁1:GPUが認識されない(Macユーザーの罠)

chrome://gpu を確認した際、AI処理に必要な WebNN ステータスが赤字の Disabled になっていました。

Windows向けの記事では触れられていませんが、Mac(Apple Silicon)でGPUアクセラレーションを効かせるには、以下のフラグ設定が必須です。

  • 解決策: chrome://flags で以下をEnabledにする
    1. Enables WebNN API
    2. Enables WebNN CoreML backend 👈 ⚠️ これが超重要!
      スクリーンショット 2025-12-27 午後11.12.50.png

これを忘れると、どれだけ頑張ってもモデルがロードされません。

壁2:モデルが降ってこない

Optimization Guide On Device Model をEnabledにしても、肝心のモデル(Text Safetyなど)がダウンロードされない現象が発生。

  • 解決策: 言語設定を英語にする
    • Chrome設定で言語を「英語 (United States)」をトップにする。
    • または、起動オプション --lang=en-US をつけて起動する。

壁3:window.ai が undefined(最大の発見)

全ての設定をクリアしても、コンソールで window.ai を叩くと undefined が返ってきます。
調査の結果、v145近辺のビルドからAPIインターフェースが変更(または隠蔽)されていることが判明しました。

これまでの定説(古い)

const session = await window.ai.languageModel.create();
// -> Uncaught TypeError: Cannot read properties of undefined

⭕️ 最新の仕様(v145+)

// いきなり LanguageModel オブジェクトが生えています
const session = await window.LanguageModel.create();
// -> 動く!!🚀

どうやら window.ai というネームスペースは廃止(整理)され、Web Neural Network API等の仕様に近づける形で LanguageModel が直接露出するようになったようです。


⚡️ 実装:右クリックAI解説拡張機能

この知見を使って、「選択したテキストを右クリックするだけで、ローカルAIが解説してくれる拡張機能」を作成しました。
APIを叩くだけでは面白くないので、実用的なChrome拡張機能(Manifest V3)に仕上げます。

ディレクトリ構成

my-local-ai-extension/
 ├── manifest.json
 └── background.js

1. manifest.json

ここで重要なのは permissions だけです。

{
  "manifest_version": 3,
  "name": "Local AI Explainer",
  "version": "1.0",
  "description": "Gemini Nanoを使って選択範囲を爆速解説します",
  "permissions": [
    "contextMenus",
    "scripting",
    "activeTab"
  ],
  "background": {
    "service_worker": "background.js"
  }
}

2. background.js (Core Logic)

ここで最大の技術的工夫があります。
通常、Chrome拡張機能の Content Script は、ページ側のグローバル変数(今回で言う window.LanguageModel)にアクセスできません。

そこで、chrome.scripting.executeScriptworld: 'MAIN' オプションを使用し、あえて「ページのコンテキスト」でスクリプトを実行させることで、Chrome標準AIへのアクセスを可能にしました。

// background.js

/**
 * ==============================================================================
 * Principal Software Engineer's Note:
 * このファイルは Service Worker として動作します。
 * DOM操作を行うロジック(runLocalAI)は、ここではなく「ページ内」で実行されるよう
 * chrome.scripting API を通じて注入されます。
 * ==============================================================================
 */

/** @const {string} コンテキストメニューのID */
const CONTEXT_MENU_ID = "explain-text-gemini-nano";

// ------------------------------------------------------------------------------
// 1. Lifecycle Management (Service Worker Context)
// ------------------------------------------------------------------------------

/**
 * 拡張機能インストール/更新時の初期化処理
 */
chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: CONTEXT_MENU_ID,
    title: "AIで解説する (Gemini Nano)",
    contexts: ["selection"],
  }, () => {
    // runtime.lastErrorへのアクセスにより、再インストール時のID重複エラーログを抑制
    if (chrome.runtime.lastError) {
      console.debug("Menu creation skipped:", chrome.runtime.lastError.message);
    }
  });
});

/**
 * メニュークリック時のハンドリング
 */
chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {
    injectPayload(tab.id, info.selectionText || "");
  }
});

/**
 * ページ内スクリプトの注入を実行する
 * @param {number} tabId - 対象のタブID
 * @param {string} selectedText - 選択されたテキスト
 */
function injectPayload(tabId, selectedText) {
  chrome.scripting.executeScript({
    target: { tabId: tabId },
    func: runLocalAI, // この関数がシリアライズされてページへ送られる
    args: [selectedText],
    world: "MAIN", // window.LanguageModel にアクセスするため必須
  }).catch((err) => {
    console.error("Critical Error: Failed to inject script.", err);
  });
}

// ------------------------------------------------------------------------------
// 2. Payload Logic (Page Context)
// 注意: この関数はブラウザのタブ内で実行されるため、外側のスコープ(background変数)にはアクセスできない。
// 必要な定数やクラスはすべてこの関数内に閉じ込める必要がある。
// ------------------------------------------------------------------------------

/**
 * ページ内で実行されるメインロジック。
 * AIサービスとUIマネージャーを内包し、解説フローを実行する。
 * * @param {string} targetText - 解説対象のテキスト
 */
async function runLocalAI(targetText) {
  // --- Constants & Config (カプセル化) ---
  const CONFIG = {
    domIds: {
      overlay: "gemini-nano-overlay",
      result: "gemini-nano-result",
      closeBtn: "gemini-nano-close",
      copyBtn: "gemini-nano-copy",
    },
    styles: {
      // CSSはメンテナンス性を考慮し、機能ごとにブロック化
      container: `
        position: fixed; top: 24px; right: 24px; width: 380px; max-height: 80vh;
        background: rgba(20, 20, 23, 0.95); backdrop-filter: blur(16px);
        color: #e0e0e0; border-radius: 12px; z-index: 2147483647;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        box-shadow: 0 24px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.1);
        display: flex; flex-direction: column; opacity: 0; transform: translateY(-10px);
        transition: opacity 0.3s ease, transform 0.3s ease;
      `,
      header: `
        padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.1);
        display: flex; justify-content: space-between; align-items: center;
        background: linear-gradient(to right, rgba(255,255,255,0.03), transparent);
        border-radius: 12px 12px 0 0;
      `,
      title: `font-weight: 700; font-size: 13px; color: #81c784; letter-spacing: 0.5px; display: flex; align-items: center; gap: 6px;`,
      body: `
        padding: 18px; overflow-y: auto; font-size: 14px; line-height: 1.6; color: #d4d4d4;
        white-space: pre-wrap; min-height: 100px;
      `,
      footer: `padding: 12px 18px; text-align: right; border-top: 1px solid rgba(255,255,255,0.05);`,
      btnBase: `
        border: none; border-radius: 6px; cursor: pointer; font-size: 11px; padding: 6px 12px;
        transition: background 0.2s; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
      `,
      btnClose: `background: #333; color: #bbb;`,
      btnCopy: `background: transparent; border: 1px solid #444; color: #888; margin-right: 0; padding: 4px 8px;`,
    }
  };

  // --- Core Logic: AI Service ---
  class AIService {
    /**
     * Gemini Nano APIの可用性をチェック
     * @returns {boolean}
     */
    static isAvailable() {
      // @ts-ignore
      return window.LanguageModel !== undefined;
    }

    /**
     * 解説を実行する
     * @param {string} text 
     * @param {(text: string) => void} onChunk 
     * @returns {Promise<void>}
     */
    static async explain(text, onChunk) {
      if (!this.isAvailable()) {
        throw new Error("Gemini Nano (window.LanguageModel) が利用できません。\nChrome Flagsを確認してください。");
      }
      
      // @ts-ignore
      const session = await window.LanguageModel.create({
        systemPrompt: "あなたは優秀な技術解説者です。難しい用語を使わず、中学生でも理解できるように3行以内で要約・解説してください。"
      });

      const stream = await session.promptStreaming(`解説してください: "${text}"`);
      
      let previous = "";
      for await (const chunk of stream) {
        if (!chunk) continue;
        // 累積テキストか差分かを考慮し、常に最新の完全なテキストをUIに送る設計
        const newContent = chunk.startsWith(previous) ? chunk : previous + chunk;
        previous = newContent;
        onChunk(newContent);
      }
      
      // session.destroy(); // API仕様が安定したら有効化
    }
  }

  // --- Presentation: UI Manager ---
  class UIManager {
    constructor() {
      this._cleanUp();
      this.el = this._buildDOM();
    }

    _cleanUp() {
      const existing = document.getElementById(CONFIG.domIds.overlay);
      if (existing) existing.remove();
    }

    _buildDOM() {
      const div = document.createElement("div");
      div.id = CONFIG.domIds.overlay;
      div.style.cssText = CONFIG.styles.container;
      
      // 安全なDOM構築
      div.innerHTML = `
        <div style="${CONFIG.styles.header}">
          <div style="${CONFIG.styles.title}">
            <span>✦</span> GEMINI NANO
          </div>
          <button id="${CONFIG.domIds.copyBtn}" style="${CONFIG.styles.btnBase} ${CONFIG.styles.btnCopy}">Copy</button>
        </div>
        <div id="${CONFIG.domIds.result}" style="${CONFIG.styles.body}">
          <span style="color: #666;">Waiting for model...</span>
        </div>
        <div style="${CONFIG.styles.footer}">
          <button id="${CONFIG.domIds.closeBtn}" style="${CONFIG.styles.btnBase} ${CONFIG.styles.btnClose}">CLOSE</button>
        </div>
      `;

      document.body.appendChild(div);

      // アニメーション用(DOM追加後に適用)
      requestAnimationFrame(() => {
        div.style.opacity = "1";
        div.style.transform = "translateY(0)";
      });

      this._bindEvents(div);
      return div;
    }

    _bindEvents(container) {
      const resultArea = container.querySelector(`#${CONFIG.domIds.result}`);
      const closeBtn = container.querySelector(`#${CONFIG.domIds.closeBtn}`);
      const copyBtn = container.querySelector(`#${CONFIG.domIds.copyBtn}`);

      if (closeBtn) closeBtn.onclick = () => this.dispose();
      
      if (copyBtn && resultArea) {
        copyBtn.onclick = () => {
          // @ts-ignore
          navigator.clipboard.writeText(resultArea.innerText);
          copyBtn.textContent = "COPIED";
          setTimeout(() => copyBtn.textContent = "COPY", 2000);
        };
      }

      this.resultArea = resultArea;
    }

    updateText(text) {
      if (this.resultArea) {
        this.resultArea.innerText = text;
        this.resultArea.scrollTop = this.resultArea.scrollHeight;
      }
    }

    showError(msg) {
      if (this.resultArea) {
        this.resultArea.innerHTML = `<span style="color:#ef5350;">⚠ ${msg}</span>`;
      }
    }

    dispose() {
      if (this.el) {
        this.el.style.opacity = "0";
        this.el.style.transform = "translateY(-10px)";
        setTimeout(() => this.el.remove(), 300); // アニメーション待機
      }
    }
  }

  // --- Main Execution Flow ---
  if (!targetText || !targetText.trim()) {
    alert("テキストを選択してください");
    return;
  }

  const ui = new UIManager();

  try {
    await AIService.explain(targetText.trim(), (chunk) => {
      ui.updateText(chunk);
    });
  } catch (e) {
    ui.showError(e instanceof Error ? e.message : String(e));
  }
}

📈 結果・成果

実際に動かしてみた結果がこちらです。
nsMQTSgGZypXKCTQ2CPF1766845613-1766845672.gif

  1. テキストを選択して右クリック → 「AIで解説する」をクリック。
  2. 一瞬で右上にウィンドウが出現。
  3. サーバー通信が一切ないため、爆速で解説が生成されます。

APIキーの発行も不要、通信量もゼロ。これがローカルLLMの真骨頂です。
また、window.LanguageModel を直接叩く方法が分かったことで、今後は既存のWebアプリにも簡単に組み込めるようになりそうです。

出力例
## CORS (Cross-Origin Resource Sharing) の解説

CORS (Cross-Origin Resource Sharing) は、ブラウザ間の安全性を確保するために導入されたセキュリティメカニズムです。ウェブサイトが異なるドメイン(つまり、異なるオリジン)からデータを取得できるように、サーバー側の設定を行う仕組みです。

**なぜ CORS が必要になったのか?**

従来のブラウザは、異なるドメインから直接データを取得することを制限していました。これは、ウェブブラウザがセキュリティ上の理由で、ユーザーの許可なく別のウェブサイトにデータを送信することを防ぐためです。

例えば、ユーザーがログインしたサイトから、別のサイトにユーザーのデータを送信してしまうと、セキュリティ上のリスクが生じます。

CORS は、ユーザーの許可を求めることなく、特定の条件下で異なるドメインからのリクエストを許可することで、この問題を解決しました。

**CORS の仕組み**

CORS の仕組みは、主に以下の要素で構成されます。

1. **ブラウザのオリジン:** ウェブブラウザのオリジンは、ドメイン、プロトコル(HTTPまたはHTTPS)、ポート番号の組み合わせで定義されます。例: `https://www.example.com`
2. **サーバー側の CORS 設定:** ウェブサーバー(例えば、Apache、Nginx、Node.jsなど)は、サーバーの HTTP レスポンスヘッダーを使用して、リクエストの許可/拒否を制御します。これらのヘッダーは、以下の情報を伝えます。

   * **`Access-Control-Allow-Origin`:** どのオリジンからのリクエストを許可するかを指定します。
     * `*`: どのオリジンからもリクエストを許可します。 (開発環境など、制限を緩和する場合に使用されることが多い)
     * 特定のオリジン名: そのオリジンからのリクエストのみを許可します。
     * `null`: リクエストを許可しません。
   * **`Access-Control-Allow-Methods`:** 使用可能な HTTP メソッド(GET, POST, PUT, DELETE など)を指定します。
   * **`Access-Control-Allow-Credentials`:** ユーザー認証が必要な場合に、認証情報を送信することを許可するかどうかを指定します。
   * **`Access-Control-Allow-Headers`:** リクエストヘッダーの許可リストを指定します。

3. **クライアント側の CORS チェック:** ウェブブラウザは、サーバー側の CORS 設定に基づいて、クライアントから送信されたリクエストの許可/拒否を判断します。

**具体的なフロー**

1. **クライアントからサーバーへのリクエスト:** クライアント (ブラウザ) がサーバーにリクエストを送信します。
2. **サーバーの検証:** サーバーは、クライアントのオリジン、リクエストの種類 (HTTP メソッド)、ヘッダーなどを検証します。
3. **CORS ヘッダーの確認:** サーバーは、`Access-Control-Allow-*` ヘッダーを検証し、許可されているオリジン、メソッド、認証情報を確認します。
4. **レスポンスヘッダーの送信:** サーバーは、CORS ヘッダーに基づいて、適切な HTTP レスポンスをクライアントに送信します。  このヘッダーには、リクエストが許可または拒否された情報が含まれます。
5. **ブラウザの判断:** ブラウザは、サーバー側のレスポンスに基づいて、リクエストを許可または拒否します。

**例:**

* **サーバー側 (Node.js):**
   ```javascript
   app.get('/api/data', (req, res) => {
     res.setHeader('Access-Control-Allow-Origin', '*'); // 全オリジン許可
     res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
     res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
     res.send('API Data');
   });
   ```
* **クライアント側 (JavaScript):**

   ```javascript
   fetch('/api/data', {
     method: 'GET',
     headers: {
       'Content-Type': 'application/json',
       'Authorization': 'Bearer YOUR_TOKEN' //認証情報
     }
   });
   
```

**CORS の設定方法 (サーバー側)**

* **Apache:** `.htaccess` ファイルで CORS ヘッダーを設定します。
* **Nginx:** 設定ファイルで CORS ヘッダーを設定します。
* **Node.js (Express):** Express の CORS middleware を使用します。

**CORS を回避する方法**

CORS は、セキュリティ上の理由から制限されるため、以下のような回避策が可能です。

* **オリジンの指定:** 特定のドメインからのアクセスのみを許可します。
* **認証の追加:** CORS を回避するために、認証を追加します。
* **Proxy の利用:** ブラウザとサーバーの間を介して、リクエストを転送する proxy を利用します。

**まとめ**

CORS は、ウェブブラウザ間のセキュリティを確保するための重要なセキュリティメカニズムです。サーバー側で適切に CORS 設定を行うことで、異なるドメインからのリクエストを許可し、より安全なウェブアプリケーションを構築できます。

この解説で CORS について理解を深めていただけたら幸いです。さらに詳しい情報については、以下の公式ドキュメントをご覧ください。

* **Mozilla CORS の公式ドキュメント:** [https://developer.mozilla.org/ja/docs/Web/API/CORS](https://developer.mozilla.org/ja/docs/Web/API/CORS)

ご不明な点があれば、お気軽にお尋ねください。

📝 まとめ

今回の検証で得られた知見は以下の通りです。

  1. 情報は鮮度が命: 特にCanaryビルドを追う場合、1ヶ月前の記事は疑ってかかる。
  2. APIの変化: window.ai は消え、LanguageModel になった(v145時点)。
  3. OSごとの癖: Macユーザーは WebNN CoreML backend を忘れてはいけない。
  4. 拡張機能のハック: ローカルAI機能にアクセスするには world: 'MAIN' が有効。

Chrome標準AIはまだ実験的な段階ですが、ブラウザだけでこれだけのことができるのは未来しか感じません。
ぜひ皆さんも、自分の環境で「Local AI Explainer」を作ってみてください!

最後に

「役に立った!」「Macで動いた!」という方は、ぜひ LGTM 👍 をお願いします!
(励みになります🚀)

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?