Novita AI Sandbox を使った Chrome プラグインの作り方

Novita AI Sandbox を使った Chrome プラグインの作り方

ドキュメントサイトやコーディングチュートリアルを閲覧しているとき、一人で全てを進める必要はありません。代わりに、ページからページへとあなたに付き従う AI アシスタントがいると想像してみてください。それは特定のウェブページに縛られることなく、常にあなたのそばにいて、すぐに助けてくれます。オンラインで出会ったコードを安全に実行し、解説を提供し、必要な瞬間に洞察を与えてくれます。

では、この体験を現実のものにするにはどうすればよいでしょうか? ブラウザ拡張機能を構築するのです。この拡張機能には、ユーザーがチャットできる AI エージェントが含まれ、そのエージェントは安全なサンドボックス環境にアクセスして、コードの実行やその他の操作を安全に行うことができます。

このシステムを作成するために、Chrome 拡張機能を構築し、ツール使用が可能な Novita モデルを使用し、エージェントの安全なランタイムとして Novita Sandbox を統合します。この記事では、その構築プロセス全体を説明します。

コードアシスタントのスクリーンショット

このチュートリアルの終わりまでに、以下のことを学べます:

  • AI エージェントと統合する Chrome 拡張機能の構築方法
  • Novita の Agentic LLM の活用方法
  • ブラウザエージェントの安全な環境としての Novita Sandbox のセットアップ方法
  • 拡張機能とエージェントのリアルタイム通信の実現方法

サンドボックス: 必要なツールはこれ一つ

私たちが構築する Chrome 拡張機能は、ユーザーを支援する AI エージェントに依存しています。この拡張機能はコーディングアシスタントとして機能することを目的としているため、エージェントはコードの実行、ファイルの作成、出力の検査、開発者が通常行うすべてのタスクを実行できる必要があります。これを実現するには多くのツールが必要に思えるかもしれませんが、実際にはたった一つで十分です: サンドボックスです。

サンドボックスは、エージェントに Linux 環境へのアクセスを提供し、コマンドの実行、ファイルの作成・変更、ターミナルで通常行うあらゆる操作を可能にします。このプロジェクトでは、Novita Sandbox を使用します。

セットアップするには、まず Novita Sandbox パッケージをインストールします:

pip install novita-sandbox

次に、NOVITA_API_KEY 環境変数に API キーを設定します。その後、以下のようにサンドボックスを作成して使用できます:

from novita_sandbox.code_interpreter import Sandbox

sandbox = Sandbox.create()

result = sandbox.commands.run('ls -l')
print(result)

sandbox.kill()

このスニペットは、サンドボックスを作成し、ls -l コマンドを実行して出力を表示し、サンドボックスをシャットダウンします。このシンプルなワークフローが、ブラウザアシスタントがサンドボックスを活用してユーザーを支援するための基盤となります。

では、この原則を拡張機能全体に適用しましょう。

ブラウザアシスタントエージェントのアーキテクチャ

このプロジェクトのアーキテクチャは、クライアント・サーバーモデルに従います。Chrome 拡張機能がクライアントとして機能し、専用のバックエンドサーバーが AI エージェントとサンドボックス環境の両方をホストします。

拡張機能は WebSocket 接続を介してサーバーと通信します。これにより、ユーザーのリクエストとエージェントの応答が即座に流れるリアルタイムの双方向メッセージングが可能になり、遅延が目立つことはありません。サーバーは、モデルエンドポイントとサンドボックスサービスを含む Novita の API と通信します。

拡張機能とバックエンドは連携して、コードを安全に実行し、情報を迅速に処理し、ユーザーのブラウジング体験の中で直接役立つ説明を提供する、インテリジェントなブラウザアシスタントを形成します。

拡張機能の構築

全体のアーキテクチャを理解したところで、拡張機能自体の実装を始めましょう。まずはプラグインサーバーから始めます。

拡張機能サーバー

拡張機能サーバーは、単一のエンドポイント /ws を持つシンプルな WebSocket サービスです。このエンドポイントはユーザーからメッセージを受信し、LLM の応答をリアルタイムで返します。また、エージェントがコードを実行したり操作を実行する必要がある場合、ツール呼び出しを処理してサンドボックスを起動します。

依存関係

サーバーは3つのコアライブラリに依存します:

  • FastAPI: WebSocket 実装を提供する HTTP フレームワーク
  • OpenAI: Novita モデルとの通信に使用する SDK
  • Novita Sandbox: コードを安全に実行するためのセキュアな環境

以下のコマンドでインストールします:

pip install novita-sandbox "fastapi[standard]" openai

Novita API キーを環境変数として設定します:

export NOVITA_API_KEY = sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

サーバーコードの記述

必要なモジュールをインポートします:

import os
import json
import uvicorn
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from novita_sandbox.code_interpreter import Sandbox
from openai import OpenAI

LLM クライアントの初期化

次に、Novita の API を指す OpenAI クライアントを作成します。この例では llama-3.3-70b-instruct モデルを使用しますが、ツール呼び出しをサポートする Novita モデルであればどれでも動作します。

client = OpenAI(
   base_url="https://api.novita.ai/openai",
   api_key=os.environ["NOVITA_API_KEY"],
)

model = "meta-llama/llama-3.3-70b-instruct"

ツールスキーマの定義

エージェントは4つのツールを使用し、それぞれがサンドボックスとやり取りします:

  • read_file: ファイルの内容を読み取る
  • write_file: 単一のファイルを作成して書き込む
  • write_files: 複数のファイルを作成して書き込む
  • run_commands: サンドボックス内でシェルコマンドを実行する

完全なツールスキーマは次の通りです:

tools = [
   {
       "type": "function",
       "function": {
           "name": "read_file",
           "description": "サンドボックス内のファイルの内容を読み取る",
           "parameters": {
               "type": "object",
               "properties": {"path": {"type": "string"}},
               "required": ["path"],
           },
       },
   },
   {
       "type": "function",
       "function": {
           "name": "write_file",
           "description": "サンドボックス内に単一のファイルを書き込む",
           "parameters": {
               "type": "object",
               "properties": {
                   "path": {"type": "string"},
                   "data": {"type": "string"},
               },
               "required": ["path", "data"],
           },
       },
   },
   {
       "type": "function",
       "function": {
           "name": "write_files",
           "description": "サンドボックス内に複数のファイルを書き込む",
           "parameters": {
               "type": "object",
               "properties": {
                   "files": {
                       "type": "array",
                       "items": {
                           "type": "object",
                           "properties": {
                               "path": {"type": "string"},
                               "data": {"type": "string"},
                           },
                           "required": ["path", "data"],
                       },
                   }
               },
               "required": ["files"],
           },
       },
   },
   {
       "type": "function",
       "function": {
           "name": "run_commands",
           "description": "サンドボックスの作業ディレクトリ内でシェルコマンドを実行する",
           "parameters": {
               "type": "object",
               "properties": {
                   "command": {"type": "string"},
               },
               "required": ["command"],
           },
       },
   },
]

HTTP サーバーの設定

FastAPI をセットアップし、Chrome 拡張機能がサーバーにリクエストできるように CORS を有効にします。

app = FastAPI()

app.add_middleware(
   CORSMiddleware,
   allow_origins=["*"],
   allow_methods=["*"],
   allow_headers=["*"],
)

ツールの実装

各ツールを実装し、エージェントのツール呼び出しをサンドボックスにルーティングするハンドラー関数を定義します:

def make_tool_handlers(sandbox):
   def read_file(path: str):
       print(f"[LOG] read_file called with path: {path}")
       try:
           content = sandbox.files.read(path)
           print(f"[LOG] read_file result: {content}")
           return content
       except Exception as e:
           return f"Error reading file: {e}"

   def write_file(path: str, data: str):
       print(f"[LOG] write_file called with path: {path}")
       try:
           sandbox.files.write(path, data)
           return f"File created successfully at {path}"
       except Exception as e:
           return f"Error writing file: {e}"

   def write_files(files: list):
       print(f"[LOG] write_files called with {len(files)} files")
       try:
           sandbox.files.write_files(files)
           return f"{len(files)} file(s) created successfully"
       except Exception as e:
           return f"Error writing multiple files: {e}"

   def run_commands(command: str):
       print(f"[LOG] run_commands called with command: {command}")
       try:
           result = sandbox.commands.run(command)
           return result.stdout
       except Exception as e:
           return f"Error running command: {e}"

   return {
       "read_file": read_file,
       "write_file": write_file,
       "write_files": write_files,
       "run_commands": run_commands,
   }

WebSocket エンドポイントの作成

次に、WebSocket エンドポイントを実装します。ユーザーがエンドポイントに接続すると、新しいサンドボックスインスタンスが作成されます。

ユーザーとエージェント間のすべての通信はこの接続を通じて行われます。ユーザーがエージェントにツールの使用を依頼すると、エージェントはハンドラー関数を介して適切なツールを選択して実行します。

接続が閉じられると、サンドボックスは終了します。

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
   await ws.accept()
   print("\
[WS] Client connected")

   # 接続ごとにサンドボックスを作成
   sandbox = Sandbox.create(timeout=1200)
   print("[WS] Sandbox created")

   tools_exec = make_tool_handlers(sandbox)
   messages = []  # この WebSocket 内で永続化

   try:
       while True:
           data = await ws.receive_text()
           print(f"[WS] Received message: {data}")

           # ユーザーメッセージを追加
           messages.append({"role": "user", "content": data})

           # LLM 呼び出し
           response = client.chat.completions.create(
               model=model,
               messages=messages,
               tools=tools,
           )

           assistant_msg = response.choices[0].message
           messages.append(assistant_msg)

           # LLM がツールを呼び出そうとしている場合
           if assistant_msg.tool_calls:
               print(f"[WS] Assistant requested {len(assistant_msg.tool_calls)} tool call(s).")

               results = []

               for tool_call in assistant_msg.tool_calls:
                   fn_name = tool_call.function.name
                   fn_args = json.loads(tool_call.function.arguments)

                   print(f"[WS] Tool call: {fn_name} args={fn_args}")

                   if fn_name in tools_exec:
                       result = tools_exec[fn_name](**fn_args)
                   else:
                       result = f"Error: Unknown tool {fn_name}"

                   results.append(result)

                   messages.append({
                       "tool_call_id": tool_call.id,
                       "role": "tool",
                       "content": str(result),
                   })

               # フォローアップモデル呼び出し
               follow_up = client.chat.completions.create(
                   model=model,
                   messages=messages,
               )
               final_answer = follow_up.choices[0].message
               messages.append(final_answer)

               await ws.send_json({
                   "reply": final_answer.content,
                   "tool_output": results,
               })

           else:
               # シンプルなモデルテキスト出力
               await ws.send_json({"reply": assistant_msg.content})

   except WebSocketDisconnect:
       print("[WS] Client disconnected")

   finally:
       sandbox.kill()
       print("[WS] Sandbox terminated")

サーバーの起動

最後に、Uvicorn を使用してサービスを起動します:

if __name__ == "__main__":
   uvicorn.run(app, host="0.0.0.0", port=8000)

これでサーバーコンポーネントが完成しました。

拡張機能

拡張機能はユーザーが操作するインターフェースです。これは、あらゆるウェブページ上で動作する一連のファイルで構成されています。アクティブ化されると、ユーザーはブラウジング中のページから直接、拡張機能サーバーとリアルタイムで通信できるようになります。

拡張機能には以下のファイルが含まれます:

  • manifest.json: 拡張機能の設定と権限を定義
  • background.js: サービスワーカーのロジックを処理し、コンテキストメニューのアクションを処理
  • content.js: ページ内のインタラクションを管理し、アシスタントダイアログを表示
  • styles.css: ページ上のアシスタントウィンドウのスタイルを提供

各ファイルには明確な責任があり、それらが組み合わさって完全で機能的な拡張機能を形成します。

拡張機能の動作

ファイルを実装する前に、ユーザーの視点から拡張機能がどのように動作するかを簡単に見てみましょう:

エージェントサンドボックスプラグインのスクリーンショット

  1. ユーザーが任意のウェブページで右クリックすると、コンテキストメニューが表示されます。
  2. メニューから「Agent Sandbox」を選択します。
  3. 拡張機能がページ上にアシスタントダイアログを開きます。
  4. ユーザーがConnectをクリックして、拡張機能サーバーとの接続を確立します。
  5. 接続が確立されると、ユーザーはメッセージボックスに直接入力を開始できます。
  6. メッセージを入力したら、Sendをクリックしてサーバーに送信します。
  7. エージェントにページコンテキストを提供したい場合は、Extractをクリックしてウェブページの表示可能なコンテンツをすべて取得できます。
  8. 手動で追加のコンテキストを追加してから、再びSendをクリックすることもできます。

コードアシスタントのスクリーンショット

ワークフローを理解したところで、拡張機能ファイルの実装を始めましょう。

manifest.json

最初に作成するファイルは manifest.json で、拡張機能の権限、バックグラウンドロジック、コンテンツスクリプトを設定します。

{
 "manifest_version": 3,
 "name": "Agent Sandbox",
 "version": "1.0",
 "description": "WebSocket 経由で Novita AI サンドボックスエージェントとチャットします。",
 "permissions": [
   "contextMenus",
   "activeTab",
   "scripting"
 ],
 "host_permissions": [
   "ws://localhost:8000/*",
   "http://localhost:8000/*",
   "https://localhost:8000/*"
 ],
 "background": {
   "service_worker": "background.js"
 },
 "action": {
   "default_icon": "icon.png"
 },
 "content_scripts": [
   {
     "matches": ["<all_urls>"],
     "js": ["content.js"],
     "css": ["styles.css"]
   }
 ]
}

このファイルは、Chrome にどのスクリプトを読み込むか、必要な権限、サービスワーカーとして機能するファイルを指定します。

background.js

バックグラウンドスクリプトは、バックグラウンドで実行されるサービスワーカーです。拡張機能を Chrome のコンテキストメニューに追加し、ユーザーの操作をリッスンする役割を担います。ユーザーがメニューオプションを選択すると、バックグラウンドスクリプトがコンテンツスクリプトにメッセージを送信し、拡張機能のダイアログをアクティブにします。

chrome.runtime.onInstalled.addListener(() => {
 chrome.contextMenus.create({
   id: "ask-assistant",
   title: "Agent Sandbox",
   contexts: ["all"]
 });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
 chrome.scripting.executeScript(
   {
     target: { tabId: tab.id },
     files: ["content.js"]
   },
   () => {
     chrome.tabs.sendMessage(tab.id, {
       type: "OPEN_PANEL"
     });
   }
 );
});

content.js

コンテンツスクリプトは、拡張機能のダイアログをウェブページ内に表示する役割を担います。バックグラウンドスクリプトからメッセージを受信すると、ダイアログを開きます。このスクリプトはプレーンな JavaScript です。標準の DOM 操作で UI を管理し、WebSocket API を使用して拡張機能サーバーと通信します。

let socket = null;

chrome.runtime.onMessage.addListener((msg) => {
 if (msg.type === "OPEN_PANEL") {
   openPanel();
 }
});

// -------------------------------------------------
// ウェブページテキスト抽出ツール
// -------------------------------------------------
function extractWebpageContent() {
 const cloned = document.cloneNode(true);
 cloned.querySelectorAll("script, style, iframe, noscript").forEach(e => e.remove());

 let main =
   cloned.querySelector("article") ||
   cloned.querySelector("main") ||
   cloned.querySelector("#content") ||
   cloned.body;

 const text = Array.from(main.querySelectorAll("h1, h2, h3, p"))
   .map(el => el.innerText.trim())
   .filter(Boolean)
   .join("\
\
");

 return text;
}

// -------------------------------------------------
// パネル UI
// -------------------------------------------------
function openPanel() {
 const old = document.getElementById("assistant-box");
 if (old) old.remove();

 const box = document.createElement("div");
 box.id = "assistant-box";

 box.innerHTML = `
   <div id="assistant-container">
     <div id="assistant-header">
       <h3>コードアシスタント</h3>
       <button id="assistant-close">×</button>
     </div>

     <textarea id="assistant-input" placeholder="エージェントにメッセージ..."></textarea>

     <div class="btn-row">
       <button id="connect-btn">接続</button>
       <button id="disconnect-btn">切断</button>
       <button id="send-btn">送信</button>
       <button id="extract-btn">抽出</button>
     </div>

     <div id="assistant-result">接続されていません。</div>
   </div>
 `;

 document.body.appendChild(box);

 document.getElementById("assistant-close").onclick = () => box.remove();

 const resultBox = document.getElementById("assistant-result");
 const inputBox = document.getElementById("assistant-input");

 // -------------------------------------------------
 // テキスト選択リスナー
 // -------------------------------------------------
 document.addEventListener("mouseup", () => {
   const selection = window.getSelection().toString().trim();
   if (!selection) return;

   // 選択テキストをメッセージボックスに追加
   inputBox.value += (inputBox.value ? "\
\
" : "") + selection;

   // テキストエリアを一番下までスクロール
   inputBox.scrollTop = inputBox.scrollHeight;
 });

 // -------------------------------------------------
 // 接続
 // -------------------------------------------------
 document.getElementById("connect-btn").onclick = () => {
   if (socket && socket.readyState === WebSocket.OPEN) {
     resultBox.innerText = "すでに接続されています。";
     return;
   }

   socket = new WebSocket("ws://localhost:8000/ws");

   socket.onopen = () => {
     resultBox.innerText = "WebSocket に接続されました。";
   };

   socket.onmessage = (event) => {
     const data = JSON.parse(event.data);
     resultBox.innerText += "\
\
アシスタント:\
" + data.reply;
     resultBox.scrollTop = resultBox.scrollHeight;
   };

   socket.onerror = () => {
     resultBox.innerText = "WebSocket エラー。";
   };

   socket.onclose = () => {
     resultBox.innerText = "切断されました。";
   };
 };

 // -------------------------------------------------
 // 切断
 // -------------------------------------------------
 document.getElementById("disconnect-btn").onclick = () => {
   if (socket) socket.close();
 };

 // -------------------------------------------------
 // メッセージ送信
 // -------------------------------------------------
 document.getElementById("send-btn").onclick = () => {
   const context = inputBox.value;

   if (!socket || socket.readyState !== WebSocket.OPEN) {
     resultBox.innerText = "接続されていません。";
     return;
   }

   socket.send(JSON.stringify({ message: context }));

   inputBox.value = "";

   resultBox.innerText += "\
\
あなた:\
" + context;
 };

 // -------------------------------------------------
 // ページ抽出 → メッセージボックスに追加
 // -------------------------------------------------
 document.getElementById("extract-btn").onclick = () => {
   const extracted = extractWebpageContent();

   if (!extracted || extracted.length < 10) {
     resultBox.innerText = "有用なコンテンツを抽出できませんでした。";
     return;
   }

   // 抽出テキストを入力ボックスに追加(自動送信はしない)
   inputBox.value += (inputBox.value ? "\
\
" : "") + extracted;

   // テキストエリアをスクロール
   inputBox.scrollTop = inputBox.scrollHeight;

   resultBox.innerText = "📄 抽出されたコンテンツをメッセージボックスに追加しました。";
 };
}

styles.css

次に、styles.css ファイルを使用してインターフェースのスタイルを設定し、拡張機能がページ上でどのように表示されるかを制御します。

#assistant-box {

 position: fixed;

 top: 10%;

 right: 10%;

 width: 350px;

 background: #000;

 border: 1px solid #00ff7f;

 border-radius: 8px;

 box-shadow: 0 0 10px rgba(0,255,127,0.5);

 z-index: 999999;

 font-family: monospace;

 color: #00ff7f;

}

#assistant-container {

 padding: 12px;

}

#assistant-header {

 display: flex;

 justify-content: space-between;

 align-items: center;

}

#assistant-close {

 background: transparent;

 border: none;

 font-size: 20px;

 cursor: pointer;

 padding: 0 5px;

 font-weight: bold;

 color: #00ff7f;

}

#assistant-container textarea {

 width: 100%;

 height: 60px;

 margin-top: 8px;

 background: #0d0d0d;

 color: #00ff7f;

 border: 1px solid #00ff7f;

 border-radius: 4px;

 padding: 6px;

 resize: vertical;

 outline: none;

}

#send-btn {

 width: 100%;

 margin-top: 10px;

 background: #00ff7f;

 border: none;

 padding: 10px;

 color: #000;

 font-weight: bold;

 cursor: pointer;

 border-radius: 4px;

 transition: 0.2s;

}

#send-btn:hover {

 background: #00e66a;

}

#assistant-box pre {

 background: #0a0a0a;

 padding: 8px;

 border-radius: 4px;

 max-height: 120px;

 overflow: auto;

 white-space: pre-wrap;

 word-break: break-word;

 margin-top: 5px;

}

#assistant-result {

 margin-top: 12px;

 background: #0d0d0d;

 padding: 8px;

 border-radius: 5px;

 white-space: pre-wrap;

 max-height: 150px;

 overflow: auto;

}

Chrome 拡張機能のセットアップ方法

必要なファイルがすべて揃ったので、次は拡張機能を Chrome に読み込みます。以下の手順に従ってください:

  1. 拡張機能サーバーのコードを Python ファイルとして保存します。
  2. すべての依存関係をインストールし、サーバーに必要な環境変数を設定します。
  3. マシン上に code-assistant-extension という名前の新しいフォルダーを作成し、拡張機能クライアント用にします。
  4. 以下のファイルをフォルダーに追加します:
    • manifest.json
    • background.js
    • content.js
    • styles.css
  5. Chrome を開き、chrome://extensions/ にアクセスします。
  6. 右上の「デベロッパーモード」を有効にします。
  7. パッケージ化されていない拡張機能を読み込む」をクリックします。
  8. 拡張機能ファイルが含まれているフォルダーを選択します。
  9. 拡張機能がツールバーに表示されます。
  10. ウェブページ上の任意のテキスト選択を右クリックすると、コンテキストメニューに「Agent Sandbox」が表示されます。

これで拡張機能を使用する準備が整いました。

リポジトリ - AI Code Assistant Browser Extension

結論

この記事では、Novita Sandbox を搭載したバックエンドに接続する Chrome 拡張機能を構築し、AI エージェントがコードを安全に実行し、ユーザーのブラウジング中に支援できるようにしました。このパターンはコーディング支援にとどまらず、インタラクティブな学習ツール、デバッグアシスタント、ドキュメント拡張機能など、さまざまな用途に応用できます。

このアーキテクチャはブラウザに依存しないため、同じアプローチを最小限の変更で最新のブラウザに適応できます。ここから、アシスタントの機能を拡張したり、UI を改良したり、新しいサンドボックスツールを追加したりすることができます。この基盤により、強力でインテリジェントなブラウザコンパニオンを作成する扉が開かれます。

Novita AI は、開発者に使いやすい API と手頃で信頼性の高い GPU インフラストラクチャを提供し、AI アプリケーションの構築とスケーリングを支援する大手 AI クラウドプラットフォームです。