5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazon Bedrock AgentCoreを用いたブラウザ拡張型AIエージェントの構成例

Posted at

はじめに

AIエージェントは、ユーザのコンテキストを踏まえて情報収集、要約、変換、判断支援までを一貫して担う存在として注目されていますが、その利用はChatGPTやCopilotなどのチャットボットを介することが多くなっています。チャットボットを介したAIエージェントの実行契機は、画面上でユーザが質問文を入力することです。これを敢えてネガティブに捉えれば、AIエージェントを利用するために、チャットボットをわざわざ開かなければならないとも言えます。

もしAIエージェントを日常的な作業と統合し、実行契機をさらに短縮または省力化できれば、より効果的に利用できると考えられます。身近な例はWebブラウジングです。業務システム、社内ポータル、SaaS、技術ドキュメント、ニュース、SNSなどを利用するために、ブラウザは日常的に使われます。チャットボットもブラウザを介して使われますが、上で挙げた他のサービスやアプリケーションとは、通常、シームレスに連携できません。

この記事では、AIエージェントを日常的な作業と統合する一事例として、WebブラウザにAIエージェントを組み込むことができれば、どのような使い勝手が得られるのか、そしてそれをどのような構成で実装できるのかを解説していきます。

類似技術との違い

本題の前に、この記事で取り上げるアプローチは、以下に取り上げる類似技術とはやや異なることを説明しておきます。

AIエージェントブラウザ

「AIエージェントブラウザ」は、AIがブラウザ内でユーザの意図を理解し、ページ内容の要約や検索支援にとどまらず、自律的な操作やタスク遂行まで担うものです。OpenAIが提供するChatGPT Atlasのように、ユーザの目標に沿った一連の行動をAIが実行する製品も現れています。これらのAIエージェントブラウザには、従来の検索と閲覧の境界を曖昧にして、ブラウジングによる情報の整理と収集に掛かる時間を短縮する効果が期待されています。

一方で、今回の記事で取り上げるのは、既存のブラウザ環境を活かして、AIエージェントとの連携を「ブラウザ拡張」として外部的に付加する構成になります。新しくAIエージェントブラウザを使うのではなく、既存のブラウザに拡張機能をインストールして、独自のAIエージェントをプラグインすることを目指します。

説明に用いる最小構成

今回は、一見分かりにくそうに見える日本語や英語の文章を、AIエージェントに推敲させて「分かりやすい日本語」に変換して表示するだけという、非常にシンプルな実装例を使って、アプローチを紹介します。

システムアーキテクチャ

今回はGoogle Chromeのブラウザ拡張から、AWSでAIエージェントをホストおよび実行するためのマネージドサービスであるAmazon Bedrock AgentCoreを間接的に呼び出して利用することを目指します。最小構成となるアーキテクチャは、以下の図の通りです。この図に倣って最小構成が完成すれば、あとは機能を継ぎ足していけば良くなります。

image.png

本質的な話をしますと、ブラウザ拡張の役目は、AIエージェントを呼び出すための指示操作を行うことと、受信した結果を表示することです。ブラウザ拡張は、これらに必要なUIを提供します。一方で、認証、推論、ログ管理、プロンプト制御といった責務は、企業利用を想定するほどブラウザ単体で抱えるべきではありませんので、AWSクラウド側に実装します。

AWSクラウド側のコンポーネント

注意
今回は最小の動作例を示すことが目的で、エンドポイントへの接続時の認証等に言及していません。ご注意ください。

(1) Amazon Bedrock AgentCore

驚かれるかもしれませんが、最小構成では、何一つコードを書かず、単純に agentcore deploy を実行するだけです。後々にツールを増やす場合には、そのためのコードを書きますが、最小構成では不要です。

デプロイ方法は、以下のドキュメントの内容に沿って、コマンドを実行するだけです。

デプロイ後に表示されるAgentCore RuntimeのARNは、次の手順で必要になりますので、メモしておきます。

(2) AWS Lambda

今回はNode.js (24.x) で作ります。以下のコードを貼り付けて、デプロイしてください。Lambda関数のタイムアウトを15分に変更しておきましょう。

import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore";
import crypto from "crypto";

const client = new BedrockAgentCoreClient({ region: "us-east-1" });

const headers = {
  "content-type": "application/json",
  "access-control-allow-origin": "*",
  "access-control-allow-headers": "content-type",
  "access-control-allow-methods": "POST,OPTIONS",
};

function newSessionId() {
  return crypto.randomUUID();
}

async function toBuffer(maybeStream) {
  if (!maybeStream) return Buffer.from("");
  if (Buffer.isBuffer(maybeStream)) return maybeStream;
  if (maybeStream instanceof Uint8Array) return Buffer.from(maybeStream);

  const chunks = [];
  for await (const c of maybeStream) {
    chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
  }
  return Buffer.concat(chunks);
}

function extractFromDataLines(raw) {
  const out = [];
  let sawData = false;

  for (const line of raw.split(/\r?\n/)) {
    const m = line.match(/^data:\s*(.*)\s*$/);
    if (!m) continue;
    sawData = true;

    const payload = m[1].trim();
    if (!payload || payload === "[DONE]") continue;

    try {
      // "..." も {"text":"..."} も JSON として読めるなら読む
      const v = JSON.parse(payload);
      if (typeof v === "string") out.push(v);
      else if (v && typeof v === "object") out.push(v.text ?? v.delta ?? v.content ?? JSON.stringify(v));
      else out.push(String(v));
    } catch {
      out.push(payload);
    }
  }

  return sawData ? out.join("") : "";
}

export const handler = async (event) => {

  try {
    const method = event?.requestContext?.http?.method;

    if (method === "OPTIONS") {
      return { statusCode: 200, headers, body: "" };
    }

    const agentArn = process.env.AGENT_RUNTIME_ARN;
    if (!agentArn) {
      return { statusCode: 500, headers, body: JSON.stringify({ error: "AGENT_RUNTIME_ARN is not set" }) };
    }

    let text = "";
    let level = 2;
    let mode = "clean";
    try {
      const parsed = JSON.parse(event?.body ?? "{}");
      text = String(parsed.text ?? "");
      level = Number(parsed.level ?? 2);
      mode = String(parsed.mode ?? "clean");
    } catch (e) {
      console.warn("Invalid JSON body:", String(e?.message ?? e));
    }

    if (!text.trim()) {
      return { statusCode: 400, headers, body: JSON.stringify({ error: "text is required" }) };
    }

    const base = `次の英文(または技術文)を、意味と手順を壊さずに、平易で自然な日本語に言い換えてください。逐語訳は避けてください。`;
    const formatClean = `出力は日本語の本文のみ。見出し、区切り線、箇条書き、補足、注釈、説明は不要です。`;

    const prompt =
      mode === "clean"
        ? `${base}\n${formatClean}\n\n【入力】\n${text}\n\n【やさしさレベル】${level}`
        : `${base}\n\n【入力】\n${text}\n\n【やさしさレベル】${level}`;

    const payload = { prompt };

    const runtimeSessionId = newSessionId();

    const cmd = new InvokeAgentRuntimeCommand({
      agentRuntimeArn: agentArn,
      runtimeSessionId,
      contentType: "application/json",
      accept: "application/json",
      payload: Buffer.from(JSON.stringify(payload)),
    });

    const res = await client.send(cmd);
    const buf = await toBuffer(res.response);
    const raw = buf.toString("utf-8");

    // まずはSSE(data: ...)形式を優先して復元
    let rewritten = extractFromDataLines(raw);

    // data: が無い場合は、JSONとして解釈して拾う
    if (!rewritten) {
      try {
        const j = JSON.parse(raw);
        rewritten = j.rewritten ?? j.text ?? j.output ?? "";
      } catch (e) {
        console.warn("AgentCore response JSON parse failed:", String(e?.message ?? e));
      }
    }

    // 空なら raw をそのまま返す
    if (!rewritten) rewritten = raw;

    return {
      statusCode: 200,
      headers,
      body: JSON.stringify({
        rewritten,
        meta: {
          level,
          sessionId: runtimeSessionId,
        },
      }),
    };

  } catch (e) {
    console.error("Unhandled error:", e);
    return {
      statusCode: 500,
      headers,
      body: JSON.stringify({
        error: "lambda_error",
        message: String(e?.message ?? e),
        name: e?.name,
      }),
    };
  }
};

次に、環境変数AGENT_RUNTIME_ARNに、メモしておいたAgentCore RuntimeのARNを設定します。

最後に、Lambda関数に割り当てられたIAMロールで、以下のポリシーを追加してください。

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "BedrockAgentCore",
			"Effect": "Allow",
			"Action": "bedrock-agentcore:InvokeAgentRuntime",
			"Resource": "*"
		}
	]
}

(3) API Gateway

HTTP APIを作成します。統合の設定で、先ほど開発したLambda関数を選択します。

POSTリクエストを受けるルートを追加します。パスは今回 ”/rewrite” としました。

image.png

CORSを設定します。今回は最小構成で動かすことだけを目的にしているため、以下のような設定です。

image.png

ここまでの作業でAPI Gatewayのエンドポイントが作成されますので、メモしておきます。このエンドポイントに対してcurlなどを実行すれば、API Gatewayを介して、Amazon Bedrock AgentCore上のAIエージェントを呼び出すことができます。

ブラウザ拡張側のコンポーネント

以下の3種類のファイルを、同じディレクトリに保存しましょう。

(1) manifest.json

API GatewayのエンドポイントのURLを、先ほどメモしておいたものに置き換えましょう。

{
  "manifest_version": 3,
  "name": "Easy JP Rewriter",
  "version": "0.1.0",
  "permissions": ["contextMenus", "activeTab", "scripting", "storage"],
  "host_permissions": ["https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/*"],
  "background": { "service_worker": "background.js" },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

(2) content.js

以下のコードをそのまま貼り付けて保存します。

let state = {
    original: "",
    rewritten: "",
    mode: "rewritten",
    status: "idle", // "idle" | "loading" | "done" | "error"
    error: ""
};

function ensureBox() {
    let box = document.getElementById("easyjp-box");
    if (box) return box;

    box = document.createElement("div");
    box.id = "easyjp-box";
    box.style.all = "initial";                 // サイトCSSの影響を遮断
    box.style.position = "fixed";
    box.style.right = "16px";
    box.style.bottom = "16px";
    box.style.maxWidth = "560px";
    box.style.maxHeight = "60vh";
    box.style.overflow = "auto";
    box.style.padding = "12px 14px";
    box.style.background = "#fff";
    box.style.color = "#111";                  // 文字色を固定
    box.style.border = "1px solid rgba(0,0,0,0.15)";
    box.style.borderRadius = "12px";
    box.style.boxShadow = "0 10px 30px rgba(0,0,0,0.15)";
    box.style.zIndex = "2147483647";
    box.style.fontFamily =
        "system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'";
    box.style.fontSize = "14px";
    box.style.lineHeight = "1.7";
    box.style.whiteSpace = "pre-wrap";
    box.style.boxSizing = "border-box";
    box.style.filter = "none";

    const bar = document.createElement("div");
    bar.id = "easyjp-bar";
    bar.style.display = "flex";
    bar.style.alignItems = "center";
    bar.style.justifyContent = "space-between";
    bar.style.gap = "8px";
    bar.style.marginBottom = "10px";

    const left = document.createElement("div");
    left.style.display = "flex";
    left.style.alignItems = "center";
    left.style.gap = "8px";

    const badge = document.createElement("div");
    badge.textContent = "EasyJP";
    badge.style.fontWeight = "600";
    badge.style.fontSize = "12px";
    badge.style.padding = "3px 8px";
    badge.style.borderRadius = "999px";
    badge.style.border = "1px solid rgba(0,0,0,0.15)";
    badge.style.background = "rgba(0,0,0,0.03)";

    const toggle = document.createElement("button");
    toggle.id = "easyjp-toggle";
    toggle.type = "button";
    toggle.textContent = "原文を表示";

    toggle.style.all = "initial";
    toggle.style.fontFamily = box.style.fontFamily;
    toggle.style.color = "#111";
    toggle.style.background = "#fff";
    toggle.style.border = "1px solid rgba(0,0,0,0.2)";
    toggle.style.padding = "6px 10px";
    toggle.style.borderRadius = "10px";
    toggle.style.cursor = "pointer";
    toggle.style.fontSize = "12px";
    toggle.addEventListener("click", () => {
        state.mode = state.mode === "rewritten" ? "original" : "rewritten";
        render();
    });

    left.appendChild(badge);
    left.appendChild(toggle);

    const close = document.createElement("button");
    close.type = "button";
    close.textContent = "×";
    close.style.all = "initial";
    close.style.fontFamily = box.style.fontFamily;
    close.style.color = "#111";
    close.style.background = "transparent";
    close.style.border = "none";
    close.style.cursor = "pointer";
    close.style.fontSize = "18px";
    close.style.lineHeight = "1";
    close.style.padding = "0 4px";
    close.addEventListener("click", () => box.remove());

    bar.appendChild(left);
    bar.appendChild(close);

    const body = document.createElement("div");
    body.id = "easyjp-body";
    body.textContent = "";

    box.appendChild(bar);
    box.appendChild(body);

    document.documentElement.appendChild(box);
    return box;
}

function render() {
    const box = ensureBox();
    const body = box.querySelector("#easyjp-body");
    const toggle = box.querySelector("#easyjp-toggle");

    if (!body || !toggle) return;

    const canToggle = state.status === "done" && (state.original || state.rewritten);
    toggle.disabled = !canToggle;
    toggle.style.opacity = canToggle ? "1" : "0.6";
    toggle.style.pointerEvents = canToggle ? "auto" : "none";

    if (state.status === "loading") {
        toggle.textContent = "原文を表示";
        body.textContent = "変換中です。少し待ってください。";
        return;
    }

    if (state.status === "error") {
        toggle.textContent = "原文を表示";
        body.textContent = `エラー:${state.error || "不明なエラー"}`;
        return;
    }

    const showingOriginal = state.mode === "original";
    toggle.textContent = showingOriginal ? "言い換えを表示" : "原文を表示";

    const text = showingOriginal ? state.original : state.rewritten;
    body.textContent = text || "(空の結果でした)";
}

chrome.runtime.onMessage.addListener((msg) => {
    if (msg?.type === "EASYJP_LOADING") {
        state.status = "loading";
        state.error = "";
        state.mode = "rewritten";
        state.original = String(msg.original ?? "");
        state.rewritten = "";
        render();
        return;
    }

    if (msg?.type !== "EASYJP_RESULT") return;

    ensureBox();

    if (msg.ok) {
        state.status = "done";
        state.error = "";
        state.original = String(msg.original ?? "");
        state.rewritten = String(msg.rewritten ?? "");
        state.mode = "rewritten";
        render();
    } else {
        state.status = "error";
        state.original = String(msg.original ?? "");
        state.rewritten = "";
        state.error = String(msg.error || "不明なエラー");
        state.mode = "rewritten";
        render();
    }
});

(3) background.js

API GatewayのエンドポイントのURLを、先ほどメモしておいたものに置き換えましょう。

const API_URL = "https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/rewrite";

function setupMenu() {
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({
      id: "easyjp",
      title: "選択範囲を平易な日本語に言い換え",
      contexts: ["selection"],
    });
  });
}

chrome.runtime.onInstalled.addListener(() => {
  setupMenu();
});

chrome.runtime.onStartup.addListener(() => {
  setupMenu();
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId !== "easyjp") return;

  const text = info.selectionText || "";
  if (!text.trim()) return;

  chrome.tabs.sendMessage(tab.id, { type: "EASYJP_LOADING", original: text });

  const level = 2;

  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();

    chrome.tabs.sendMessage(tab.id, {
      type: "EASYJP_RESULT",
      ok: res.ok,
      original: text,
      rewritten: String(data.rewritten ?? ""),
      error: data.error || data.message,
    });
  } catch (e) {
    chrome.tabs.sendMessage(tab.id, {
      type: "EASYJP_RESULT",
      ok: false,
      original: text,
      rewritten: "",
      error: String(e?.message ?? e),
    });
  }
});

ブラウザ拡張をインストールする

今回のような小規模な検証段階で動かしてみたい、または閉じられた環境のみで使用したい場合、ブラウザ拡張を公開しなくてもインストールする方法があります。Google Chromeの拡張機能の設定画面にある「デベロッパーモード」を有効化した上で「パッケージ化されていない拡張機能を読み込む」を選択し、先ほどの3つのファイルを保存したディレクトリを選ぶだけで、インストールが完了します。

image.png

ブラウザ拡張を実行する

Google Chromeで任意のWebサイトを開き、インストールしたブラウザ拡張を実行してみます。実行方法は、Webサイトの文章を選択して右クリックし「選択範囲を平易な日本語に読み替え」を押すだけです。指示文を入力することなく、AIエージェントを呼び出しています。

送信元の文章が英語の場合にも対応しています。日本人にとって読みにくく感じる機械翻訳調の文章ではなく、AIエージェントによって推敲された、比較的読みやすい文章になっています。

image.png

まとめ

この記事では、Google Chromeを例に、ブラウザ拡張をインターフェースとして、Amazon Bedrock AgentCoreにデプロイしたAIエージェントを利用する方法を解説しました。ブラウザ拡張は、多くの方が考えているよりも開発の敷居が低く、既存の使い勝手を維持しながら、比較的小さな開発規模でAIエージェントの利用を始められます。

今回実装した最小構成は、単純に文章を変換するだけのものですが、これを目の当たりにするだけでも、アイデアが次々と浮かんできます。例えばAIエージェントに次の深掘りをするための質問を考えさせたり、過去の調査との関連を調べさせたり、自律的にレポートを書かせたり、可能性は無限大です。

余談ですが、最近、私がこだわっているのは、もはや指示文と呼ばれるものを書かなくても、乱文を投げつけるだけで指示から解釈してくれるエージェントです。その意味でも、文章を選択して右クリックだけでAIエージェントを起動するアプローチは、この姿に迫ったのではないかと思います。

AIエージェントを簡単かつ手軽に利用する方法の1つとして、ブラウザ拡張を起点として、色々と突き詰めてみると、面白いかもしれません。

参考文献

5
2
1

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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?