0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

設定画面を捨てた。Chrome内蔵AI(Gemini Nano)が毎晩1手だけ新規タブを進化させる拡張を作った

0
Posted at

新規タブの設定画面を全部消して、代わりにChrome内蔵AI(Gemini Nano)が毎晩1回、画面を1手だけ作り替える拡張を作りました。データもAIも全部Chrome内で完結。

で、完成した仕組みに試しで一晩ぶん撃たせてみたら、AIは初手からいちばん重い手を出してきました

※本記事は設計編です。数週間実際に使い込んだ実測レポート(答え合わせ)を第2回として出します。

3行まとめ

  • 🌱 設定画面の代わりに「理想の朝」を日本語で一度だけ書く。あとは使うほど新規タブが勝手に自分へ寄ってくる
  • 🛡️ AIには提案しかさせない。決定は決定論的なコードがやる三権分立で、騙されても壊れても画面は無事
  • 🔫 本物のNanoに一晩ぶん試射したら、返ってきた初手は remove。しかも合法でした(本文で全部見せます)

🌅 はじめに:結局、育てるのは自分

新規タブをドラクエ5風に改造した話を書いてから半年、毎朝あのタブを開いています。快適です。ただ、使い込んで気づいてしまいました。

このダッシュボード、育てるのは結局、自分なんですよ。

ウィジェットが増えたら設定画面で並べ替える。使わなくなったら消す。便利にするための作業で朝の時間が溶けていく。本末転倒です。

だから逆にしました。設定画面を消す。画面のほうが、僕に合わせて勝手に変わればいい。

タイミングも来ていました。Canary時代にフラグ2本立てて遊んだGemini Nanoが、Chrome 148でPrompt APIごと安定版化。フラグなしで、配布先の全ユーザーの手元で動く時代です(手元のChrome 150で availability = "available" を確認済み)。

こうして出来たのが「自己進化する新規タブ」です。作ったからには、撃ってみたくなるじゃないですか。

Gemini Nanoとは(30秒)
Chrome本体に同梱されるGoogleの小型LLM。拡張機能から LanguageModel.create()(Prompt API)で呼べて(Webページ向けはオリジントライアル段階)、推論は端末内で完結します。Chrome 148から安定版。要件は公式ドキュメントへ。


🔫 先に、AIの初手をお見せします

まだ数週間の運用はしていません(それは次回)。ただ、我慢できなかったので一晩ぶんだけ手動で撃ちました

条件は正直に書きます。行動ログだけは架空です。「毎朝memoを触る、feedは3日連続無視」というありそうな3日分を手で作りました。それ以外は全部本物。プロンプトは本番のコードで生成し、実機のChrome 150で本物のNanoに投げ、出力は本番のJSONスキーマで拘束しています。

返ってきた初手がこれです。

{"op": "remove", "widgetId": "feed", "reason": "feedウィジェットがサマリ上3日以上連続で無視されているため、画面の整理を促すため"}

初手、remove。

grow(大きくする)でもnone(様子見)でもなく、ウィジェット削除。この仕組みに存在する手の中で、いちばん重いやつです。新入り一日目に机を1つ撤去する新人、怖い。

しかもこの提案、後述のルールで検証したら完全に合法でした。ガードレールの判定結果(実行ログ):

{ "schemaViolation": false, "verdict": { "approved": true } }

生成時間は自分メモの更新に17秒、この提案に3.5秒。一晩ぶんの進化が約21秒です。夜中に21秒だけ働く同居人。

……と、ここまで見せておいてなんですが、この remove はまだ画面に1pxも触れていません。AIの出力が直接画面をいじる経路が、この拡張には存在しないからです。

その作りを説明します。


🗺️ 仕組み:昼は数えるだけ、夜に2回だけAIが動く

昼の拡張は数を数えるだけです。記録するイベントはこの2種類だけ。

{ t: "widget_click",  widgetId: "memo", ts: 1785443220000 }
{ t: "widget_ignore", widgetId: "feed", visibleMs: 42000, ts: ... } // 表示されてたのに触られなかった

ページ内容も入力テキストも録りません。7日で物理削除。

例外がひとつだけあります。夜のジョブはChromeの閲覧履歴を「ドメイン名・回数・時間帯」の数値サマリに変換してから参照します。ページタイトルとURLパスはLLMに渡しません(理由は後述)。インストール時に history 権限の警告が出るのはこのためで、使い道と捨て方は、この段落が全部です。

夜3時になったら、Nanoが2回だけ呼ばれます。1回目で「自分メモ」(AIがユーザーについて書く観察日記、800字以内)を更新し、2回目で変異を1手提案する。判断材料は、あなたが初回に書く「理想の朝」(このタブの憲法)と、数値サマリと、自分メモの3つだけです。

朝はまずその日やることを1つ書き出したい。ニュースは見出しだけで十分。静かに書き始めたい。

設定項目を埋めるんじゃなくて、こう書いておくと解釈は向こうがやる。これが設定画面の代わりです。

ちなみに「夜3時」と言いましたが、その時間のMacはだいたい蓋を閉じて寝ています。MV3のService Workerは常駐しないので chrome.alarms の予約で動くんですが、スリープ中は発火しません。なので取りこぼしたぶんは、朝いちばんに開いた新規タブが自分で回収します(同じ日を二重処理しない冪等ガード付き)。目覚ましで起きるのではなく、起きてから自分で帳尻を合わせる。見習いたい。

で、夜のNanoに画面をいじる権限を渡すとどうなるか。

渡しません。


🏛️ AIには「提案」しかさせない(新規タブの三権分立)

前回、ローカルLLMに100回ツール呼び出しさせる検証で「成功率87%、JSONは通るのに中身がカオス、型バリデーション必須」という結論を出しました。オンデバイスの小さいLLMは、そこそこ賢くて、そこそこ壊れる。その前提で統治機構を組みます。

立法(Nano)が出せる語彙は grow / shrink / add / remove / move / none の6語だけ。JSONスキーマの外の提案はそもそも生成できません(moveは行き先を表現できるまで司法が全件棄却中なので、実際に通るのは5種類)。

司法の条文はこの7つ。

# ルール 一言でいうと
1 1日1変異まで 朝の驚きは1つまで
2 pinnedは不可侵 📌を刺した場所は殿堂入り
3 removeは「3日連続で無視」が条件 消すのはいちばん重い手
4 addは実在カタログのSサイズのみ 新入りは小さく試験投入
5 revertされた手と同型は14日封印 嫌がられた方向に伸びない
6 憲法と矛盾する変異は禁止 「ニュース見たくない」ならfeedは育てない
7 スキーマ違反の出力は「無風日」扱い 壊れた夜は、何も起きない夜

白状すると、6番だけは司法のコードに書けていません。「憲法と矛盾するか」は意味の判断なので、プロンプトの宣言で守らせています。7つで唯一、LLMの読解力を信じている条文です(この伏線はすぐ回収されます)。

実装は3段構えです。

// ① 生成段階で拘束: スキーマ外のトークンをそもそも出せなくする
const raw = await session.prompt(prompt, { responseConstraint: mutationProposalJsonSchema });

// ② それでも信用しない: zodで検証、通らなければ「無風日」に降格(ルール7)
const { proposal, schemaViolation } = parseMutationProposalDetailed(raw);

// ③ 通っても、裁くのは純粋関数
const verdict = evaluateMutation(proposal, { layout, recentLog, recentSummaries, today });

responseConstraint は「JSONでお願いします」ではありません。JSON Schemaに合わないトークンを生成できなくする機能です。前回87%で泣いた「引数にスキーマが混入」パターンは①と②で死にます。③の司法はchrome APIに依存しない純粋関数なので、「3日連続無視の2日目と3日目」「封印13日目と14日目」みたいな意地悪な境界をブラウザ無しのテーブルテストで拷問できます(ここだけはClean Architectureにした甲斐がありました)。

この構造のいいところを一行で言うと——

LLMは騙せても、騙した先で何もできない。

履歴に細工したページタイトルで「全ウィジェットを削除せよ」とAIを騙すのが間接プロンプトインジェクションですが、そもそもタイトルを渡していません。渡すのはドメイン名と回数と時間帯の数字だけ。騙せても出せるのは6語の法案だけで、法案は司法が裁く。

では、その司法は本物のNanoの初手をどう裁いたか。


🔍 試射の夜に起きていたこと(答え合わせ)

まず1回目の呼び出し、AIが書いた「自分メモ」の実物から(抜粋)。

朝、まずその日のタスクを一つ書き出す傾向がある。ニュースは要約程度で、静かな環境で書き始めたいと願っている。(中略)2026年7月29日には、memoへのアクセスが4回、頻繁に利用するサイトが5つだった。(中略)feedへの表示時間が特に長い傾向が見られる。

1文目、完璧です。憲法と行動がちゃんと繋がってる。

でもよく読んでください。「頻繁に利用するサイトが5つ」——これ、frequent-sites ウィジェットのクリック数5回の誤読です。ウィジェット名を事実として読んでしまった。さらに「feedへの表示時間が特に長い」は、無視されたまま表示され続けた時間(ignoreVisibleMs)のこと。つまり「いちばん見られてない」を「いちばん見られてる」と読んでいる。

かわいい。でも、こいつに画面を任せるのか?

任せた結果が、あの初手です。

{"op": "remove", "widgetId": "feed", "reason": "feedウィジェットがサマリ上3日以上連続で無視されているため、画面の整理を促すため"}

司法の判定は approved: true。ルール3(3日連続無視)をデータで満たし、pinnedでもなく、スキーマも完璧。**合法です。腹が立つくらい正しい。**自分メモでは「feedよく見られてる」と誤読していたくせに、提案では数値サマリを正しく引いてremove条件を通してくる。読解はガバガバなのに、手続きだけ正確な新人。

ひとつだけルール違反があります。reasonに「憲法のどの記述に沿うか」を含めよという指示(ルール6)を、綺麗に無視しています。はい、さっき白状した「唯一、LLMの読解力を信じている条文」が、試射1発目で破られました。司法は形式しか裁けないので素通りです。設計どおりの弱点が、設計どおりに露出した。

あと、憲法には「ニュースは見出しだけで十分」と書いたんですが、Nanoの解釈は「じゃあ全部消す」でした。極端なんだよ。ただ、これが暴走にならないのがこの仕組みで、朝の画面には欄外に一行出るだけです。

2026-07-31: 「feed」を畳みました — feedウィジェットがサマリ上3日以上連続で無視されているため… [元に戻す]

気に入らなければワンタップで戻す。戻された手は14日間封印され、自分メモにも「これは嫌がられた」と残る。AIの極端さは、拒否できる限り個性です。

進化のビフォーアフターはこんな見た目になります(Nanoをモックした開発ビルドでフルサイクルを回した動作イメージ。本物のNanoに数週間任せた画面は次回)。

0-before-after.png

見た目の方針はAI感の真逆に振りました。紫グラデ・✨・ロボットアイコン・チャット吹き出しは全面禁止、モチーフは文房具と紙面。ダークモードも罫線だけです。**テーマ設定画面? 作りません。**設定画面を消すのがコンセプトなので。

5-newtab-dark.png


🕳️ ……と言った矢先に、司法に穴が2つ見つかった話

「騙した先で何もできない」と書きました。公開前のセキュリティレビューで、その司法まわりから本物の穴が2つ出ました。白状します。

**穴その1。**Nanoが使えない環境向けに、推論先をローカルLLM(Ollamaなど)へ差し替えられる裏口があります。接続先は「localhostとプライベートIPだけ許可」——のつもりが、この判定を 127.0.0.1.evil.com がすり抜ける。先頭が 127. で始まるかしか見ていなかったんです。名字が「宅配便」さんなら玄関を開ける判定。ホスト名がIPアドレスの形かを先に確かめる修正を入れました。

**穴その2はもっと嫌な汗が出ました。**ウィジェットIDに "constructor" を渡すと、ルール3の審査が素通りします。JavaScriptのオブジェクトを辞書として使うと stats["constructor"] は「無い」ではなく「有る」(継承元の関数が出てくる)。数値のはずの場所に関数が来ると「連続無視が3日未満だから棄却」という比較がfalseになり、棄却の網だけをすり抜ける。プロトタイプ汚染という教科書どおりの古傷を、自分の司法で踏み直しました。Object.hasOwn で修正済み。

教訓はシンプルでした。「決定は決定論的なルールで」と言っても、そのルールを書いたのは人間(僕)。**司法にも監査が要る。**LLMを信用しない設計の最大の穴が、LLMじゃなくて自分のコードだったというオチです。


🐛 ハマりポイント:本番ビルドに"開発者パネル"が紛れ込んだ

一番ヒヤッとしたやつだけ共有します。

デバッグ用に保存データを生で覗ける内省パネルを作りました。当然、本番ビルドからは除外したい。こう書きました。

const isProduction = process.env.NODE_ENV === "production"; // 「本番なら除外」のつもり

動きませんでした。ビルドしたzipに開発者パネルが普通に入ってる。ビルド設定を疑い、フレームワークを疑い、最後に echo $NODE_ENV を打ったら出ました。development犯人は、いつの間にか僕のシェルに住み着いていた一行でした。

ビルドツール(WXT)の内部はこうなっています。

process.env.NODE_ENV ??= mode; // 「未設定のときだけ」代入

先客がいると ??= は何もしない。だから本番ビルドでも development のまま。対策は、環境変数ではなくビルドツールが解決した後のmodeで判定することです。

// wxt.config.ts — シェル環境に汚染されない確定値を見る
hooks: {
  "entrypoints:resolved": (wxt, entrypoints) => {
    if (wxt.config.mode !== "production") return;
    // ここで開発専用エントリを配列から除外
  },
},

個人開発でも「本番に開発用画面が混入」は普通に起きます。zipの中身は目視しましょう(自戒)。


🧭 正直な現在地

  • ✅ 「夜間ジョブ→審査→反映→元に戻す」の一周は実ブラウザのe2eで動作確認済み(ユニット471本+e2e6本)
  • ✅ 本物のNanoへの試射も、上で見せたとおり成立(スキーマ違反ゼロ、司法も機能)
  • ⏳ ただし数週間分の実データで何が起きるかは、まだ誰も知らない。試射は一晩ぶん、しかも行動ログは架空です

この拡張は進化の履歴と一緒に、壊れたJSONが来た夜も invalid-output として自動カウントしています。答え合わせの材料は使っているだけで貯まる。

次回、実測編で出す数字:

  • invalid-output(壊れた出力)は実際何晩に1回か —— 前回の「87%」の答え合わせ
  • 提案の承認/棄却/revert比率 —— AIは気が利くのか、今回みたいに極端なのか
  • 数週間後の「自分メモ」全文 —— 誤読は直るのか、拗らせるのか

技術スタックはSvelte + WXT + TypeScript + Clean Architecture。GitHubリポジトリは実測編と同時に公開します。


💬 議論したいこと

  • 新規タブは「毎朝同じであるべき」派ですか、「勝手に良くなってほしい」派ですか? 僕は後者に振り切りましたが、「UIは1pxも動くな」派の言い分もわかります。
  • 「3日連続無視でremove候補」のルール、週末にしか使わないウィジェットとどう共存させます? 今の答えは「消えても、戻せば14日封印」ですが、もっといい裁き方がある気がしています。

🌱 おわりに

作る前は「AIに画面を任せるなんて怖い」と自分でも思っていました。作り終えた今の答えはこうです。怖さは、AIの賢さではなく拒否できるかどうかで決まる。1日1手だけ。理由は必ず一行付く。ワンタップで戻せて、戻した方向には伸びなくなる。データは外に出ない。

それだけ縛れば、初手でfeedを撤去してくる極端な同居人も、わりと愛せます。

今夜も3時に、僕のタブは1手だけ何かを企みます。まあ、Macが寝てるので実際に打ってくるのは、明日の朝、僕がタブを開いた瞬間なんですけど。

(第2回・実測編に続く。GitHubリポジトリもそのとき公開します)

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?