はじめに

下記の文章は Gemini 3.8 Flash(Medium)に全部作ってもらいました。
スクリーンショットだけ私のほうで準備して入れました。
追記:13日早朝にリリースされた Gemini 4 Hydrogen(Medium)で推敲しました。

PLaMo 3 Translate との出会いと、完全ローカルAI翻訳への想い

きっかけは、株式会社Preferred Networks(PFN)から翻訳特化型 LLM「PLaMo 3 Translate」が発表されたことでした。
「ローカル環境で動くオープンウェイトのモデルなのに、こんなに高精度で自然な日本語に訳せるの!?」――0.8B や 2B といった軽量サイズまで公開され、文脈を汲み取った翻訳や敬体(です・ます)/常体(だ・である)の切り替えまでこなす姿に、かなりの衝撃とワクワクを覚えました。

💡 PLaMo 3 Translate 公式情報

「この精度の翻訳が、仕事の下調べやプライベートな文章を外部クラウドに送らず手元で使えるなら最高じゃないか!」
そう思い立った私は、まず PC の llama-server や Android の PocketPal AI など、手元のローカルサーバーと通信して Web ページを翻訳する拡張機能『PLaMo3』を自作して使い始めました。

ところが、毎日使っていると「翻訳したいと思った瞬間に、毎回サーバーを立ち上げて接続するのが地味に面倒……!」という壁にぶつかります。
それならいっそ推論エンジンごと拡張機能の中に埋め込んでしまおう!と進化させたのが、PLaMo 3 Translate をブラウザ単体で直接動かせる『Local PLaMo Translator (v3)』(PLaMo3-v3)です。

今回の開発は、設計から高速化、そして謎の不具合のデバッグまで、AI エージェント(VS Code の Google Antigravity)と相談しながらの二人三脚。「お、劇的に速くなった!」と喜んだ矢先に「ブラウザを再起動したら拡張機能が消えるんですけど!?」と頭を抱えるなど、なかなか濃い開発記になったので記録に残しておきます。

1. 作ったもの:完全オフライン・APIキー不要の翻訳拡張

一言でいうと、「外部サーバーも API キーも不要。初回にモデルをキャッシュすれば、あとは完全オフラインで動く翻訳拡張機能」です。PC でもスマホでも同じ感覚で使えるように作りました。

  • ページ全体の対訳並記翻訳: 原文を消さずに、各段落のすぐ下へ訳文を差し込みます。画面に見えている範囲から優先して訳し始め、進捗バッジから「原文のみ / 対訳表示」の切り替えや「中止」もワンクリックで行えます。
  • 選択テキストのポップアップ翻訳: 文字を選ぶと近くに小さなアイコンが出現。開いたカード上で敬体(です・ます)/常体(だ・である)を切り替えたり、原文を直接編集して Ctrl+Enter で再翻訳したり、ページ上の文字をそのまま置換したりできます。ヘッダを掴んでドラッグ移動も OK。
  • 端末に合わせたモデル選択: 軽量・省メモリな「0.8B(約 573MB)」と、PC 向け・高精度な「2B(約 1.4GB)」を切り替え可能。初回ダウンロード後はブラウザ内にキャッシュされるため、2回目以降は通信ゼロで動きます。
  • ハイブリッド対応: もちろん、外部のローカルサーバー(llama-server / Ollama / LM Studio 等)やクラウド API(OpenRouter 等)への切り替えにも対応しています。

選択したテキストの翻訳

PLaMo3 選択したテキストの翻訳01
選択したテキストの最後にアイコンが出ます。
PLaMo3 選択したテキストの翻訳02
アイコンをクリック(タップ)すると翻訳開始します。

ページ全体翻訳

PLaMo3 ページ全体の翻訳
ページ全体翻訳は原文の下に訳を出します。

2. 開発環境と「あえてのビルドレス」構成

今回の開発環境と技術スタックはこんな感じです。

  • 開発マシン&エディタ: Linux Mint 上の VS Code(Google Antigravity)。普段は Windows 11 とも行き来するマルチ OS 運用なので、調査メモや設計方針は Notion にまとめながら進めました。
  • ターゲットブラウザ: PC 版および Android 版の Vivaldi。最近の Android 版 Vivaldi(8.2 以降)は拡張機能に対応しているため、「同じ拡張機能を PC とスマホの両方で動かす」ことを目標にしました。
  • 技術構成: Webpack や Vite といったビルドツールをあえて使わない、素の HTML / CSS / Vanilla JS(Manifest V3)+ バンドル済み @wllama/wllama(v3.8.1)。「思い立ったら 1 行書き換えて即リロード」できる身軽さを優先しました。

3. 裏側の仕組み:ブラウザ内で LLM を動かす4つの工夫

「Chrome 拡張機能の中で 500MB〜1.4GB の LLM を動かす」と聞くと重そうに思えますが、裏側では次の 4 つの仕組みで快適さを保っています。

  • ① Offscreen Document に推論エンジンを常駐させる: Manifest V3 のバックグラウンド(Service Worker)は、数十秒操作しないと自動でスリープしてしまいます。そのたびに 1GB 近いモデルを読み直していては使い物にならないため、offscreen 権限で寿命の長い隠しページ(offscreen.html)を開き、そこに Wasm 版 llama.cpp である wllama を常駐させています。
  • ② PFML3 プロンプト&予約スロット付き並列ストリーミング: 入力文を PLaMo 3 Translate 公式の PFML3 フォーマット(<|plamo:tag|> 等)に整形して推論します。wllama 側は並列スロットを N_PARALLEL = 3 に設定し、「ページ翻訳で 2 スロット使っていても、ポップアップやパネル翻訳用に 1 スロットの余裕を残す」構成に。Port 通信で生成された文字からリアルタイムに画面へ流し込んでいます。
  • ③ メモリ LRU + IndexedDB の 2 段キャッシュ: 同じ文やナビゲーションなどの繰り返し要素を毎回推論するのはもったいないので、モデル・訳先言語・文体・原文の SHA-256 ハッシュをキーにしたキャッシュ(メモリ 200 件 + IndexedDB 5,000 件)を実装しました。ヒットすればモデルを動かす前に一瞬で訳文を返します。
  • ④ COOP / COEP ヘッダでマルチスレッドを解放する: ここが隠れた重要ポイントでした。wllama は本来マルチスレッド(SharedArrayBuffer)で高速に動きますが、manifest.json に cross_origin_embedder_policy (require-corp) と cross_origin_opener_policy (same-origin) を設定しないと、セキュリティ制限で自動的に 1 スレッド縛りになってしまいます。これを有効化し、ポップアップ下部に「CPU (4スレッド)」のように稼働状態が見えるようにもしました。

4. ここが大変だった!AIとの泥臭い格闘ポイント4選

ここからが開発記の本番です。AI と一緒に機能を磨き上げていく中で、いくつもの「え、そうなるの!?」という壁にぶつかりました。

苦労①:「短い見出しまで長文の道連れに!」バッチ待ちの罠

最初はページ全体を「2段落ずつバッチでまとめて投げ、両方終わったら画面に出す」方式にしていました。するとどうなるか。1行目の短い見出しが 0.3 秒で訳し終わっても、ペアになった2行目が超長い段落だと、見出しの表示まで 4 秒待たされてしまうんです。そこで「空きスロットができた順に 1 段落ずつ流し込み、Port 通信で生成途中の文字から即画面へ出す」パイプライン方式へ書き換えたところ、体感速度が劇的に改善しました。

苦労②:『No available adapters.』亡霊エラー事件

翻訳を動かしたあと、拡張機能のエラー欄に必ず「No available adapters.(offscreen/offscreen.html:0)」という謎のエラーが居座っていました。offscreen.js にはすでにエラーを握りつぶす処理を書いていたのに、なぜかすり抜けてくる……。AI とコードを追いかけると、犯人は別スレッドの Web Worker(wllama-worker.js)の中にいました。Worker 内の未処理エラーは親ページの window リスナーには届かないんですね。Worker 側の先頭にも self 向けのガードと WebGPU モックを仕込んで、ようやく亡霊を退治できました。

苦労③:「中断」と「中止」と「✕」どれが正解?引き算の UX 設計

ページ翻訳中に出る小さな進捗ウィンドウのボタンでも悩みました。「途中で止めたとき、ここまで訳した文は残したい。でも誤操作なら全部消したい。じゃあ『停止』と『削除』を分けて、さらに再開ボタンも足す……?」と、小窓のボタンがどんどん増えそうに。そこで AI と相談し、「小窓はごちゃつかせないのが一番。『中止』または『✕』を押したら推論を即止めて、訳し終わった分だけ残して閉じる。続きを訳したければもう 1 回押せばキャッシュから一瞬で通過する」という引き算の設計に落ち着きました。キャッシュを作ったおかげで UX までシンプルになったのは嬉しい誤算です。

苦労④:最大のホラー『PC でもスマホでも、Vivaldi を再起動すると拡張機能が消える』

一番頭を抱えたのがこの怪奇現象です。同じ親フォルダにある他の自作拡張や旧 PLaMo3 は再起動してもピンピンしているのに、開発中だった PLaMo3-v2 だけは PC でもスマホでも、Vivaldi を再起動するたびに拡張機能一覧から跡形もなく消えてしまうんです。しかも拡張機能をパッケージ化(.crx)して入れると消えない。なぜ非パッケージのときだけ!?

AI と切り分け作戦を立て、「Manifest だけ最小にした Test A」と「中身を抜いた Test B」を作って Vivaldi で再起動してみると……なんと両方とも残る。「え、じゃあ中身が揃うとダメなの?」と、今度は「中身そのまま・フォルダ名だけ変えた完全コピー Test C」を試したところ、再起動しても、モデルを読み込んで翻訳したあとで再起動しても、あっさり残ってしまいました。

結局のところ、今のコード自体に問題はなく、以前のエラーや起動時(onStartup)の offscreen 即時生成がきっかけで、Vivaldi のプロファイル側に「元のフォルダパスは異常」という負のキャッシュが残っていたのが原因でした。最終的に、起動時の offscreen 即時生成をやめたうえでフォルダ名を「PLaMo3-v3」へ改名したら、非パッケージのまま再起動してもバッチリ残るように!そしてスマホ版は更新の手間を減らすため .crx(秘密鍵 .pem は絶対に Git に上げないよう .gitignore で保護!)で運用する二刀流に落ち着きました。


おわりに:AI と作る個人開発は最高に楽しい

ブラウザの中でローカル LLM がスルスルと日本語を紡ぎ出す光景は、何度見てもちょっと感動します。しかも今回は、先行する素晴らしい OSS の工夫に学びつつ、切り分け用テスト版の作成からキャッシュ設計、果ては「UI のボタンをどう引き算するか」の相談まで AI と壁打ちしながら進められたおかげで、1人では心が折れそうな Vivaldi の怪奇現象まで楽しく乗り切れました。

「自分の欲しいツールを、自分の手で(AI と一緒に)サクッと作って毎日使う」――個人開発の醍醐味がぎゅっと詰まった、最高に楽しい開発になりました!