はじめに
前回の記事では、Google Chromeのブラウザ拡張機能からAmazon Bedrock AgentCoreを間接的に呼び出して利用する、シンプルなAIエージェント搭載アシスタントを実装しました。前回の実装は、ブラウザ上の右クリックで起動でき、画面を行き来することなく、お手軽にAIエージェントを利用できるというものでした。
これを実際に使ってみると、確かに使い勝手の良さをある程度感じましたが、毎回画面を開いたり、出てきたポップアップを閉じたりするのも面倒に感じてきました。何かの作業をしながら、その横でいつでもすぐにAIエージェントに話しかけられる状態の方が、本当の便利さを実感できるはずです。
そこで今回は、前回の実装を更新して、見た目と使い方の部分を大きく変えてみます。Chromeの画面にサイドパネルを作り、AIエージェントが常に表示されているような見せ方に作り変えることを目指します。
目指す姿
最終的に作りたいものをイメージしていただくために、まず動画(上の方)をご覧ください。
ChromeでWebサイトを見ているとき、画面の右側にAIエージェントがずっと表示されている状態を作ります。
動画を見ると分かるように、左側に表示されているWebサイトで文字を選んで、右側のボタンを押すだけで、前回作った「文章変換」の機能が使えるようになっています。
前バージョン(下の動画)と比べると、どれだけ使いやすくなったかが分かると思います。
事前準備
前回の記事を見ながら、まず前バージョンのAIエージェントとブラウザ拡張機能を実際に作った後、今回の改良作業を始めていきます。
システムアーキテクチャ
前回と同じく、Google Chromeの拡張機能を使って、AWSのマネージドサービスであるAmazon Bedrock AgentCoreにアクセスし、AIエージェントを動かします。
必要最小限の構成は図の通りで、前回との違いはサイドパネルを実装するためのHTMLとJavaScriptが増えている点です。また、既存のブラウザ拡張機能のコードは、サイドパネルで動かすことを想定して作り直す必要があり、これから示すコードに書き換えていきます。
変更および追加したコンポーネント
前回のコードを、以下で示すコードに置き換えていきます。
(1) manifest.json
サイドパネル表示に対応させます。コンテキストメニューの設定項目 ContextMenus を削除し、新たに sidePanel を加え、さらにside_panel.default_path を指定しています。また、拡張機能のアイコンをクリックしたときにサイドパネルが開くように、background.js を後ほど変更します。
API GatewayのエンドポイントのURLを、前回の記事に倣って作成したものに置き換えましょう。
{
"manifest_version": 3,
"name": "Easy JP Rewriter",
"version": "0.2.0",
"permissions": ["activeTab", "scripting", "storage", "sidePanel"],
"host_permissions": ["https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/*"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "EasyJP" },
"side_panel": { "default_path": "sidepanel.html" },
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
(2) sidepanel.html / sidepanel.js
HTMLファイルは manifest.json の side_panel.default_path からリンクされています。サイドパネル表示に必要不可欠なファイルです。
以下のコードを貼り付けて manifest.json と同じディレクトリに保存します。
sidepanel.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>EasyJP</title>
<style>
body { font-family: system-ui, -apple-system, "Hiragino Kaku Gothic ProN", "Noto Sans JP", Segoe UI, Roboto, Arial; margin: 0; padding: 12px; }
.badge { display: inline-block; font-weight: 600; font-size: 12px; padding: 3px 8px; border-radius: 999px; border: 1px solid rgba(0,0,0,0.15); background: rgba(0,0,0,0.03); }
.row { margin-top: 10px; }
label { display: block; font-size: 12px; color: #333; margin-bottom: 6px; }
select, button { width: 100%; box-sizing: border-box; }
select { padding: 8px; border-radius: 10px; border: 1px solid rgba(0,0,0,0.2); background: #fff; }
button { padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(0,0,0,0.2); background: #fff; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: default; }
.sub { display: flex; gap: 8px; margin-top: 8px; }
.sub button { width: auto; flex: 1; }
.box { margin-top: 12px; border: 1px solid rgba(0,0,0,0.15); border-radius: 12px; padding: 10px 10px; background: #fff; }
.title { font-size: 12px; color: #333; margin-bottom: 6px; font-weight: 600; }
pre { margin: 0; white-space: pre-wrap; word-break: break-word; line-height: 1.7; font-size: 13px; color: #111; }
.hint { margin-top: 8px; font-size: 12px; color: #666; line-height: 1.6; }
.err { color: #b00020; }
</style>
</head>
<body>
<div class="badge">EasyJP</div>
<div class="row">
<label>やさしさ</label>
<select id="level">
<option value="1">1 ほぼ原文の意味のまま、軽く読みやすく</option>
<option value="2" selected>2 技術用語は残しつつ、短い文で言い換え</option>
<option value="3">3 かなりやさしく、補足も入れる</option>
</select>
</div>
<div class="row">
<button id="convert">選択中の文章を変換</button>
<div class="sub">
<button id="toggle" disabled>原文を表示</button>
<button id="copy" disabled>コピー</button>
</div>
</div>
<div class="box">
<div class="title" id="statusTitle">結果</div>
<pre id="output">(まだ変換していません)</pre>
</div>
<div class="hint">
使い方は「ページ上で文章を選択 → 変換」です。<br>
サイドパネルは開いたまま固定(ピン)できます。
</div>
<script src="sidepanel.js"></script>
</body>
</html>
sidepanel.html
let state = {
status: "idle", // idle | loading | done | error
mode: "rewritten", // rewritten | original
original: "",
rewritten: "",
error: ""
};
const levelEl = document.getElementById("level");
const convertBtn = document.getElementById("convert");
const toggleBtn = document.getElementById("toggle");
const copyBtn = document.getElementById("copy");
const statusTitle = document.getElementById("statusTitle");
const outputEl = document.getElementById("output");
function render() {
const canToggle = state.status === "done" && (state.original || state.rewritten);
toggleBtn.disabled = !canToggle;
copyBtn.disabled = !canToggle;
if (state.status === "idle") {
statusTitle.textContent = "結果";
outputEl.textContent = "(まだ変換していません)";
toggleBtn.textContent = "原文を表示";
return;
}
if (state.status === "loading") {
statusTitle.textContent = "変換中";
outputEl.textContent = "変換中です。少し待ってください。";
toggleBtn.textContent = "原文を表示";
return;
}
if (state.status === "error") {
statusTitle.textContent = "エラー";
outputEl.textContent = state.error || "不明なエラー";
outputEl.classList.add("err");
toggleBtn.textContent = "原文を表示";
return;
}
outputEl.classList.remove("err");
const showingOriginal = state.mode === "original";
toggleBtn.textContent = showingOriginal ? "言い換えを表示" : "原文を表示";
statusTitle.textContent = showingOriginal ? "原文" : "言い換え";
const text = showingOriginal ? state.original : state.rewritten;
outputEl.textContent = text || "(空の結果でした)";
}
toggleBtn.addEventListener("click", () => {
state.mode = state.mode === "rewritten" ? "original" : "rewritten";
render();
});
copyBtn.addEventListener("click", async () => {
const text = state.mode === "original" ? state.original : state.rewritten;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// 今回、失敗時は何もしない
}
});
convertBtn.addEventListener("click", async () => {
state.status = "loading";
state.error = "";
state.mode = "rewritten";
render();
chrome.runtime.sendMessage({
type: "EASYJP_CONVERT",
level: Number(levelEl.value || 2)
});
});
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type !== "EASYJP_PANEL_RESULT") return;
if (msg.ok) {
state.status = "done";
state.original = String(msg.original ?? "");
state.rewritten = String(msg.rewritten ?? "");
state.error = "";
state.mode = "rewritten";
} else {
state.status = "error";
state.original = String(msg.original ?? "");
state.rewritten = "";
state.error = String(msg.error || "不明なエラー");
state.mode = "rewritten";
}
render();
});
render();
(3) background.js
前回の右クリックメニューで動く仕組みをやめて、サイドパネルから指示を受け取って変換を実行する仕組みに変更します。
まず、今開いているタブに対して GET_SELECTION と定義したメッセージを送り content.js から選択中のテキストを受信します。変換が終わったら、結果をEASYJP_PANEL_RESULT としてサイドパネルに返します。
API GatewayのエンドポイントのURLを、前回の記事に倣って作成したものに置き換えましょう。
const API_URL = "https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/rewrite";
chrome.runtime.onInstalled.addListener(async () => {
try {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
} catch {
// 未対応なら無視
}
});
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
async function getSelectionFromTab(tabId) {
const res = await chrome.tabs.sendMessage(tabId, { type: "GET_SELECTION" });
return String(res?.text || "").trim();
}
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type !== "EASYJP_CONVERT") return;
(async () => {
const level = Number(msg.level || 2);
const tab = await getActiveTab();
const tabId = tab?.id;
if (!tabId) {
chrome.runtime.sendMessage({
type: "EASYJP_PANEL_RESULT",
ok: false,
original: "",
rewritten: "",
error: "アクティブなタブが見つかりませんでした。"
});
return;
}
let text = "";
try {
text = await getSelectionFromTab(tabId);
} catch {
text = "";
}
if (!text) {
chrome.runtime.sendMessage({
type: "EASYJP_PANEL_RESULT",
ok: false,
original: "",
rewritten: "",
error: "変換したい英文をページ上で選択してから押してください。"
});
return;
}
try {
const res = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text, level, mode: "clean" })
});
const data = await res.json().catch(() => ({}));
chrome.runtime.sendMessage({
type: "EASYJP_PANEL_RESULT",
ok: res.ok,
original: text,
rewritten: String(data.rewritten ?? ""),
error: data.error || data.message
});
} catch (e) {
chrome.runtime.sendMessage({
type: "EASYJP_PANEL_RESULT",
ok: false,
original: text,
rewritten: "",
error: String(e?.message ?? e)
});
}
})();
});
(4) content.js
非常にシンプルで、background.js から GET_SELECTION と定義したメッセージを受け取ったとき、選択中の文字列を返します。
function getSelectionText() {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return "";
return sel.toString();
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== "GET_SELECTION") return;
sendResponse({ text: getSelectionText() });
return true;
});
ブラウザ拡張を更新する
全てのファイルを保存し終えたら、前回の記事でインストールしたブラウザ拡張を更新して、変更を反映します。
更新方法は、インストールされている拡張機能の画面で、拡張機能の名称の右端にある、更新ボタンを押すだけです。
ブラウザ拡張を実行する
実行すると、先ほどの動画のような動きとなり、ブラウジング中の画面を閉じることなく、AIエージェントを使用することができます。
まとめ
今回は、前回作った機能はそのままに、Chromeの横に開いておけるサイドパネルとして作り直してみました。右クリックやポップアップを経由せず、ブラウザで調べものをしながらそのまま使えるようになり、相棒感が増しています。この使い勝手を、ローカルにファイルを保存してインストールするだけで得られるのも、お手軽で良いポイントです。
この形から、例えば文章を変換して整える機能以外に、要約や翻訳、用語の説明といった機能を増やしても、利用者は画面を切り替える手間を感じずに済むでしょう。Amazon Bedrock AgentCoreで開発したAIエージェントには、MCPや独自ツールを接続できるため、容易に機能を増やしていくことができます。
ブラウザは普段の仕事の中心にあり、そこに違和感なく溶け込むAIエージェントは非常に使い勝手が良く、現実解の一つだと感じています。今回はサイドパネル化を非常に簡単な例で説明しましたが、今後は具体的に仕事に役立つ機能を追加していければと思います。
参考文献

