Google Gemini に相談

提供: MeryWiki
2024年12月21日 (土) 18:12時点におけるYuko (トーク | 投稿記録)による版
ナビゲーションに移動 検索に移動

概要

Googleの生成AIである「Gemini」を利用するためのMeryマクロです。

このマクロは、以下の流れで動作します:

  • ユーザーがエディタ内でテキストを選択する (未選択の場合、アクティブ行が対象になる)
  • 選択したテキストをプロンプトとしてGemini APIに送信する
  • APIからの応答を取得し、エディタに表示する
  • 応答内容は履歴として保持し、次回のリクエストに利用する

後述のソースコードは、従来版スクリプトエンジンで動作するものとV8エンジンで動作するものの2種類を掲載しています。

V8エンジン版の追加仕様:

  • ストリーミングモードでリアルタイムに1文字ずつMeryに入力されていくため、さながらGeminiチャットのような雰囲気を楽しむことができる
  • Shiftキー長押しで、テキスト生成を中断できる

注意事項 (必ず読んでからご利用ください)

  • 本マクロでは 2024/11/17 時点で無料利用ができるGemini APIを利用します。今後、Googleの改定により有償になったり、設定誤り等で費用が発生しても、作者は一切の責任を負いません。
  • Gemini APIではその特性上、プロンプトとして送信した文字列がAIの学習に使われるものと思われます。くれぐれも機密情報やプライバシーに関わる情報を指定しないように注意してください。
  • Gemini APIは、 Gemini API利用規約 に則ってご利用ください。

利用前提

利用方法

  • 環境変数の設定
コマンド プロンプトで以下のコマンドを実行し、環境変数を設定してください。
setx GOOGLE_API_KEY "***************************************"
設定後、Mery または Windows の再起動が必要な場合があります。
  • マクロの実行
    • プロンプトにしたい文字列を選択してマクロを実行すると、選択したテキストがプロンプトとしてGemini APIに送信され、応答がエディタに表示されます。
  • 会話の履歴数を調整
    • 会話の履歴は設定した個数分が保持され、文脈として利用されます。適宜、ソースコード内の「HISTORY_SIZE」の数を調整してください。
    • ※数を増やすほど会話内容を保持して文脈を理解するようになりますが、メモリ使用量が増えたり動作が重くなる可能性があります。
    • ※開いている文書ごとに履歴を保持します。文脈をリセットしたいときは、新しい文書を開いて会話をやり直してください。

ソースコード (V8エンジン版)

#language = "v8"
#title = "Gemini に相談 (V8版)"
BeginUndoGroup();

// 会話の履歴を保持する数 (質問と回答で1セット)
// 数を増やすと、会話の履歴が増えますが、Meryのメモリ使用量が増えたり、
// 動作が重くなる可能性があるため、適宜調整してください。
// document.Tag に保持されるため、開いているファイルを閉じると履歴は消えます。
const HISTORY_SIZE = 5;

// 生成AI応答の入力毎の遅延時間 (ミリ秒)
const INPUT_DELAY = 20;

// 1回の入力での文字数
const INPUT_LENGTH = 3;

// コンテキストメニュー番号
const EXECUTE_AI = 1;
const RESET_HISTORY = 2;

const ConsoleClass = function () {
    this.log = function (str) {
        outputBar.Writeln(str)
    }
}
const console = new ConsoleClass();

const doc = document;
const sel = document.selection;

// 生成リクエスト中止フラグ
let abortRequestFlg = false
// 生成リクエスト完了フラグ;
let requestCompletedFlg = false;

main();

async function main() {
    const selectionIsEmpty = sel.IsEmpty

    // プロンプトの取得
    if (selectionIsEmpty) {
        sel.StartOfLine(false, mePosLogical);
        sel.EndOfLine(true, mePosLogical);
    }
    const promptText = sel.Text.trim();
    const promptLength = sel.TextLength;
    while (true) {
        // コンテキストメニューの作成
        const menu = createContextMenu(selectionIsEmpty);
        const menuTrack = menu.Track(0);
        // メニュー選択がキャンセルされた場合は終了
        if (menuTrack === 0) return;

        // 会話履歴のリセット
        if (menuTrack === 2) {
            resetTag();
            continue
        }
        break;
    }

    // プロンプトが空の場合はエラー
    if (promptLength === 0) {
        alert("プロンプトを入力してください。");
        return;
    }
    // 文字数が多すぎる場合はエラー
    if (promptLength > 10000) {
        alert("プロンプトの文字数が多すぎます。10000 文字以下にしてください。");
        return;
    }
    // 読み取り専用の場合はエラー
    if (doc.ReadOnly) {
        alert("ファイルが読み取り専用のため実行できません。");
        return;
    }

    try {
        // 処理終了を待機
        shell.KeepRunning = true;

        // カーソル位置移動
        sel.SetActivePoint(mePosLogical, sel.GetBottomPointX(mePosLogical), sel.GetBottomPointY(mePosLogical));
        sel.EndOfLine(false, mePosLogical);
        sel.NewLine();

        // APIキーの取得
        const apiKey = shell.getEnv("GOOGLE_API_KEY");

        // Gemini API に問い合わせ
        sel.Text = "\n---\n\n"
        await Promise.all([
            callGeminiAPI(apiKey, promptText),
            abortRequest()
        ]);
        sel.Text = "\n\n---\n\n\n"
    } catch (e) {
        console.log("エラーが発生しました: " + e.message + "\n" + e.stack);
        sel.Text = "<処理中にエラー発生>"
    } finally {
        // 処理終了待機を解除
        shell.KeepRunning = false;
    }
}

function createContextMenu(selectionIsEmpty) {
    const menu = CreatePopupMenu();
    menu.Add("※注意※ プライバシー・機密情報を送信しないこと", 0);
    menu.Add("", 0, meMenuSeparator);
    if (selectionIsEmpty) {
        menu.Add("アクティブ行の内容で相談 (&Q)", EXECUTE_AI);
    } else {
        menu.Add("選択範囲の内容で相談 (&Q)", EXECUTE_AI);
    }
    if (document.Tag.exists("gemini-text")) {
        const turn = JSON.parse(document.Tag["gemini-text"]).length / 2;
        menu.Add("会話履歴をリセット (現在の会話数: " + turn + ") (&R)", RESET_HISTORY);
    }
    return menu;
}

// Shift キーが長押しされた場合はリクエスト中止フラグを立てる
async function abortRequest() {
    const VIRTUAL_KEY_CODE_SHIFT = 0x10;
    let pressCount = 0;
    while (!requestCompletedFlg) {
        await new Promise(resolve => setTimeout(resolve, 300));
        if (shell.GetKeyState(VIRTUAL_KEY_CODE_SHIFT) < 0) {
            pressCount++;
            if (pressCount > 2) {
                abortRequestFlg = true;
                return;
            }
        }
    }
}

async function callGeminiAPI(apiKey, promptText) {
    const controller = new AbortController();
    const signal = controller.signal;
    try {
        // fetch で Gemini API にリクエスト
        // stream: true, stream_options: { "include_usage": true } で利用トークン数を取得
        const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:streamGenerateContent?alt=sse&key=' + apiKey, {
            signal: signal,
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(createGeminiRequest(promptText)),
        });

        let responseMessage = "";
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8');
        while (true) {
            const { done, value } = await reader.read();
            if (done) {
                break;
            }

            // 受信したバイナリデータ (value) をテキストに変換する
            const text = decoder.decode(value);

            // テキストには複数のメッセージが含まれている可能性があるため、
            // 改行 (LF) で分離して、1 行ずつ処理する
            const lines = text.trim().split(/\n+/);

            for (const line of lines) {
                const json_text = line.replace(/^data:\s*/, '');
                // データがない場合はスキップ
                if (!json_text) {
                    continue;
                }

                const data = JSON.parse(json_text);

                const candidate = data.candidates[0];

                // まとまった文字数で入力する
                const inputText = formatGeminiResponse(candidate.content.parts[0].text);
                if (inputText) {
                    // HACK 絵文字などの文字幅が2以上の文字を考慮して配列変換を行う
                    const inputArray = Array.from(inputText);
                    for (let i = 0; i < inputArray.length; i += INPUT_LENGTH) {
                        // リクエスト中止フラグが立っている場合は中断
                        if (abortRequestFlg) {
                            controller.abort();
                            sel.Text = "<処理を中止>";
                            return;
                        }
                        // 入力
                        sel.Text = inputArray.slice(i, i + INPUT_LENGTH).join(''); // 配列をスライスして文字列に戻す
                        await new Promise(resolve => setTimeout(resolve, INPUT_DELAY));
                    }
                    responseMessage += inputText;
                }

                // 応答が終了した場合はループを抜ける
                if (candidate.finishReason === "STOP") {
                    break;
                }
            }
        }

        // 結果を会話履歴に追加
        if (HISTORY_SIZE > 0) {
            appendReturnTextToTag(promptText, responseMessage);
        }
    } catch (e) {
        controller.abort();
        throw e;
    } finally {
        requestCompletedFlg = true;
    }
}

// Geminiの応答には半角スペースが連続して含まれることがあるため、整形する
function formatGeminiResponse(response) {
    return response
        .replace(/([。!?]) +/g, "$1")
        .replace(/([*:])  +/g, "$1 ");
}

// document.Tag の内容を加味してGeminiのリクエストを作成
function createGeminiRequest(promptText) {
    const tag = document.Tag;
    // 以前の会話が存在しない場合
    if (!tag.exists("gemini-text")) {
        return { "contents": [createRoleAndParts("user", promptText)] };
    }

    const geminiText = JSON.parse(tag["gemini-text"]);
    geminiText.push(createRoleAndParts("user", promptText));
    return { "contents": geminiText };
}

function createRoleAndParts(role, text) {
    return {
        "role": role,
        "parts": [
            {
                "text": text
            }
        ]
    };
}

// document.Tag にGeminiの応答を追加
function appendReturnTextToTag(userText, modelText) {
    const tag = document.Tag;
    if (!tag.exists("gemini-text")) {
        tag["gemini-text"] = JSON.stringify([
            createRoleAndParts("user", userText),
            createRoleAndParts("model", modelText)
        ]);
    } else {
        const geminiText = JSON.parse(tag["gemini-text"]);
        geminiText.push(createRoleAndParts("user", userText));
        geminiText.push(createRoleAndParts("model", modelText));
        // 会話セット (user, model 1往復) が特定セット数を超えた場合、最初のセットを削除
        while (geminiText.length > HISTORY_SIZE * 2) {
            geminiText.shift();
        }
        tag["gemini-text"] = JSON.stringify(geminiText);
    }
}

function resetTag() {
    if (document.Tag.exists("gemini-text")) {
        document.Tag.remove("gemini-text");
    }
}

ソースコード (従来エンジン版)

#title = "Gemini に相談"
BeginUndoGroup();

// 会話の履歴を保持する数 (質問と回答で1セット)
// 数を増やすと、会話の履歴が増えますが、Meryのメモリ使用量が増えたり、
// 動作が重くなる可能性があるため、適宜調整してください。
// document.Tag に保持されるため、開いているファイルを閉じると履歴は消えます。
var HISTORY_SIZE = 5;

// コンテキストメニュー番号
var EXECUTE_AI = 1;
var RESET_HISTORY = 2;

var ConsoleClass = function () {
    this.log = function (str) {
        outputBar.Writeln(str)
    }
}
var console = new ConsoleClass();

var doc = document;
var sel = document.selection;

main();

function main() {
    var selectionIsEmpty = sel.IsEmpty
    var beforeScrollY = ScrollY;

    // プロンプトの取得
    var promptText = "";
    var promptLength = 0;
    if (selectionIsEmpty) {
        sel.StartOfLine(false, mePosLogical);
        sel.EndOfLine(true, mePosLogical);
    }
    promptText = sel.Text.trim();
    promptLength = sel.TextLength;
    // コンテキストメニューの作成
    var menu = createContextMenu(selectionIsEmpty);
    var menuTrack = menu.Track(0);
    // メニュー選択がキャンセルされた場合は終了
    if (menuTrack === 0) return;

    // 会話履歴のリセット
    if (menuTrack === 2) {
        resetTag();
        return;
    }

    // プロンプトが空の場合はエラー
    if (promptLength === 0) {
        alert("プロンプトを入力してください。");
        return;
    }
    // 文字数が多すぎる場合はエラー
    if (promptLength > 10000) {
        alert("プロンプトの文字数が多すぎます。10000 文字以下にしてください。");
        return;
    }
    // 読み取り専用の場合はエラー
    if (doc.ReadOnly) {
        alert("ファイルが読み取り専用のため実行できません。");
        return;
    }

    // カーソル位置移動
    sel.SetActivePoint(mePosLogical, sel.GetBottomPointX(mePosLogical), sel.GetBottomPointY(mePosLogical));
    sel.EndOfLine(false, mePosLogical);
    sel.NewLine();
    var activeY = sel.GetActivePointY(mePosLogical);

    // Gemini API に問い合わせ
    doc.ReadOnly = true;
    var ret = callGeminiAPI(promptText);
    doc.ReadOnly = false;

    // 結果を表示
    sel.Text = "\n---\n\n" + ret + "\n\n---\n";

    // 位置調整
    sel.SetActivePoint(
        mePosLogical,
        1,
        activeY,
        false
    )
    if (beforeScrollY + 2 < ScrollY) {
        ScrollY = ScrollY - 1;
    }
}

function createContextMenu(selectionIsEmpty) {
    var menu = CreatePopupMenu();
    menu.Add("※注意※ プライバシー・機密情報を送信しないこと", 0);
    menu.Add("", 0, meMenuSeparator);
    if (selectionIsEmpty) {
        menu.Add("アクティブ行の内容で相談 (&Q)", EXECUTE_AI);
    } else {
        menu.Add("選択範囲の内容で相談 (&Q)", EXECUTE_AI);
    }
    if (document.Tag.exists("gemini-text")) {
        var turn = JSON.parse(document.Tag("gemini-text")).length / 2;
        menu.Add("会話履歴をリセット (現在の会話数: " + turn + ") (&R)", RESET_HISTORY);
    }
    return menu;
}

function callGeminiAPI(promptText) {
    try {
        // APIキーの取得
        var gemini_api_key = shell.getEnv("GOOGLE_API_KEY");

        var http = new ActiveXObject("Msxml2.ServerXMLHTTP");
        var url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=' + gemini_api_key;

        http.open('POST', url, true);
        http.setRequestHeader('Content-Type', 'application/json');
        http.send('{"contents":' + createGeminiRequest(promptText) + '}');

        // 応答待機
        var timeout = 60;
        var retrySec = 0;
        var startX = sel.GetActivePointX(mePosLogical);
        var prevPos = sel.GetActivePos();
        while (http.readyState != 4) {
            if (retrySec > timeout) {
                break;
            }
            http.waitForResponse(1);
            retrySec += 1;
            inputProgressChar(prevPos);
            prevPos = sel.GetActivePos();
        }
        // 進捗表示文字を削除
        sel.SetActivePoint(
            mePosLogical,
            startX,
            sel.GetActivePointY(mePosLogical),
            true
        );
        sel.Delete();

        var ret = "";

        // 応答ステータスチェック
        if (http.readyState == 4 && http.status == 200) {
            // JSONをパースして必要なテキストを抽出
            var response = JSON.parse(http.responseText);
            if (response.candidates && response.candidates.length > 0) {
                response.candidates.forEach(function (candidate) {
                    if (candidate.content && candidate.content.parts) {
                        candidate.content.parts.forEach(function (part) {
                            if (part.text) {
                                ret += part.text;
                            }
                        });
                    }
                });
            }
        } else {
            var ret = "エラーが発生しました: " + http.status + " " + http.statusText
            http.abort();
            return ret;
        }

        // 結果を会話履歴に追加
        if (HISTORY_SIZE > 0) {
            appendReturnTextToTag(promptText, ret);
        }

        // 整形しつつ返す
        return ret
            .trim()
            .replace(/([。!?]) +/g, "$1")
            .replace(/\*  +/g, "* ");
    } catch (e) {
        console.log(e.stack);
        return "エラーが発生しました: " + e.message;
    }
}

// document.Tag の内容を加味してGeminiのリクエストを作成
function createGeminiRequest(promptText) {
    var tag = document.Tag;
    // 以前の会話が存在しない場合
    if (!tag.exists("gemini-text")) {
        return JSON.stringify([{ "parts": [{ "text": promptText }] }]);
    }

    var geminiText = JSON.parse(tag("gemini-text"));
    geminiText.push({
        "role": "user",
        "parts": [{ "text": promptText }]
    });
    return JSON.stringify(geminiText);
}

// document.Tag にGeminiの応答を追加
function appendReturnTextToTag(userText, modelText) {
    var tag = document.Tag;
    if (!tag.exists("gemini-text")) {
        tag("gemini-text") = JSON.stringify([
            {
                "role": "user",
                "parts": [{ "text": userText }]
            },
            {
                "role": "model",
                "parts": [{ "text": modelText }]
            }
        ]);
    } else {
        var geminiText = JSON.parse(tag("gemini-text"));
        geminiText.push({
            "role": "user",
            "parts": [{ "text": userText }]
        });
        geminiText.push({
            "role": "model",
            "parts": [{ "text": modelText }]
        });
        // 会話セット (user, model 1往復) が5セットを超えた場合、最初のセットを削除
        while (geminiText.length > HISTORY_SIZE * 2) {
            geminiText.shift();
        }
        tag("gemini-text") = JSON.stringify(geminiText);
    }
}

function resetTag() {
    if (document.Tag.exists("gemini-text")) {
        document.Tag.remove("gemini-text");
    }
}

function inputProgressChar(pos) {
    doc.ReadOnly = false;
    sel.SetActivePos(pos);
    sel.Text = ".";
    doc.ReadOnly = true;
}

更新履歴

  • 2024-12-18 会話履歴の保持方式をJSON文字列に変更。リクエスト確認のポップアップ表示をコンテキストメニュー表示に変更。同メニューに会話履歴リセット機能を追加
  • 2024-11-28 文書内で初回実行する際、「"」が含まれるとエラーになってしまう事象に対処
  • 2024-11-27 サンプルマクロ Google Gemini で執筆支援 の仕様に合わせ、環境変数「GOOGLE_API_KEY」からAPIキーを取得するように変更。これに合わせてMeryの動作バージョンを Ver 3.7.8 以上に変更
  • 2024-11-17 初版
スポンサーリンク