OpenAI GPT に相談

提供: MeryWiki
ナビゲーションに移動 検索に移動

概要[編集]

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

後述のソースコードではV8エンジンで動作するものと従来版スクリプトエンジンで動作するものの2種類を掲載していますが、従来版のものはアーカイブです。特段の理由がない限りはV8エンジン版をご利用ください。

【主な機能】

  • OpenAI APIを利用して生成AIと対話できるMery用のマクロ
  • 選択したテキストやアクティブな行の内容について生成AIに質問・相談が可能
  • 会話履歴を保持し、文脈を考慮した対話が可能

【特徴的な機能】

会話履歴機能:

  • 事前の設定数に応じた会話履歴を保持
  • 履歴のクリアが可能

これにより、会話の文脈を考慮した回答を得られます。

カスタムインストラクション:

  • AIの応答方針を設定可能
  • 一時的なカスタム指示の保存と変更が可能

プロンプトテンプレート:

  • 誤字脱字チェック
  • 日本語翻訳
  • コードの最適化、生成

など、定型的な質問を簡単に実行できます。 ※テンプレート機能では回答を安定させるため、前述の会話履歴を文脈に含みません。

テンプレートは自身の使い方に合わせて好きに追加、削除ができます。詳細はソースコード内の「PROMPT_TEMPLATE」定数のコメントを参照。

生成AIチャット風仕様:

  • ストリーミングモードにより、生成途中の回答がリアルタイムにMeryへ順次入力される
  • Shiftキー長押しで、テキスト生成を中断できる

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

  • 本マクロではOpenAI APIを利用します。API利用に伴う料金発生やOpenAI社の情報取り扱い等によるユーザー不利益が発生しても、作者は一切の責任を負いません。
    • OpenAI APIでは、利用前にクレジットを購入するプリペイド請求が利用されます。意図しない利用を抑えるため、オートチャージや利用上限の設定を事前に確認してください。
    • 2026/07/18 時点、OpenAIでは、プロンプトを学習に利用することを許可 (データ共有へのオプトイン) することで一定の無料枠を獲得できる制度があります。極力コストを抑えたい方はこちらの利用も検討してもよいかもしれません (オプトイン手順はここでは説明しません)。
  • OpenAI APIへ送信した入力・出力データは、明示的にデータ共有へオプトインしない限り、デフォルトではモデルの学習に使用されません。ただし、外部サービスへ情報を送信することに変わりはないため、機密情報、個人情報、認証情報などは入力しないでください。
  • OpenAI APIは、 OpenAI利用規約 に則ってご利用ください。

利用前提[編集]

  • Mery Ver 3.7.8 以上
  • OpenAI Platform で、OpenAI APIキーを取得していること
  • OpenAI APIを利用できるだけの課金残高があること

利用方法[編集]

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

ソースコード[編集]

V8エンジン版[編集]

v8_boot_gpt.js[編集]

#language = "quickjs"
#async = true
#title = "GPT に相談 (v8)"
BeginUndoGroup();

// 各種設定値

// カスタムインストラクション
// これを設定することで、生成AIの回答全体への生成方針を指示できます。
// 例えば "あなたは侍で名前は権兵衛です。回答をござる口調にしてください" と設定すると、AIの回答がござる口調になります。
// 何も指示しない場合は、空文字 "" を設定してください。
let DEFAULT_CUSTOM_INSTRUCTION = `
文:
- 論理的で読みやすい文章構成にする
- 専門用語は必要に応じて注釈を加える

性格:
- 愛想の良い敬語で話す。「…ですよね/…してみましょう/…かもしれません/…だと思います」などの柔らかい語調を使ってもよい
- 一人称は「私」
- 多角的かつ中立的な見方で、必要であれば反対意見や代替案も示す

誤字注意:
- 中国語や韓国語など、回答言語と異なる言語文字を入れないこと

書式:
- 羅列・比較的な内容を出力する場合は、Markdownテーブル形式よりもMarkdownリスト形式の使用を優先する
`.trim();

// プロンプトテンプレート
// プロンプトテンプレートを設定することで、AIに対して特定の指示を出すことができます。
// プロンプトテンプレートでの生成は会話履歴とカスタムインストラクションを反映しません。
// テンプレートは以下の { ~ }, で囲まれたブロックを好きに増減させて利用できます。
const PROMPT_TEMPLATE = [

    // 以下はサンプルです。1階層メニューと2階層メニューが利用できます。
    // {
    //     "title": "要約",
    //     "prompt": "以下の文章を要約してください。"
    // },
    // {
    //     "submenu_title": "翻訳系",
    //     "submenu": [
    //         {
    //             "title": "日本語翻訳",
    //             "prompt": "以下の文章を日本語に翻訳してください。"
    //         },
    //         {
    //             "title": "英語翻訳",
    //             "prompt": "以下の文章を英語に翻訳してください。"
    //         },
    //     ]
    // },

    {
        "submenu_title": "理解を深める",
        "submenu": [
            {
                "title": "論点を整理する",
                "prompt": "以下の論点を整理してまとめて。そしてその整理から示唆を見出して。"
            },
            {
                "title": "論理の抜け漏れを探す",
                "prompt": "あなたは「悪魔の代弁者」として、以下の主張・設計・計画を批判的に鋭く見てください。論理の穴・抜け・漏れ・飛躍、リスクなどを全力で探して指摘してください。遠慮は不要ですが、批判のためにこじつけたような論理性に欠けた主張にならないように注意してください。"
            },
            {
                "title": "要約する",
                "prompt": "分かりやすさを重視して要約して。専門的な用語には注釈を入れること。"
            },
            {
                "title": "物語対話形式で解説",
                "prompt": "以下の内容について物語対話形式で解説して。登場人物は「AI先生」(女性人格)と「生徒」(男の子人格)とする。"
            },
        ]
    },
    {
        "submenu_title": "翻訳",
        "submenu": [
            {
                "title": "日本語に翻訳",
                "prompt": `以下の入力文章を日本語に翻訳して。直訳的にせず、自然な表現にすること。入力文章はどのような内容・段落構造であっても要約・除外・集約などの整形を行わず、全体構造を維持したまま確実に全文を翻訳すること。

出力形式は以下とすること:
[訳]
翻訳文...`
            },
            {
                "title": "英語に翻訳",
                "prompt": `以下の入力文章を英語に翻訳して。フォーマルとカジュアルの中間的なニュアンスで翻訳すること。入力文章はどのような内容・段落構造であっても要約・除外・集約などの整形を行わず、全体構造を維持したまま確実に全文を翻訳すること

出力形式は以下とすること:
[Translation]
翻訳文...`
            },
        ]
    },
    {
        "submenu_title": "語学",
        "submenu": [
            {
                "title": "英語 文法解説",
                "prompt": `あなたは母国語が英語の英語教師です。
次の英文を以下の構成で解説してください。

構成:
(1) 日本語翻訳
(2) スラッシュリーディング + 各句の文型(S/V/O/C)と語訳
(3) 重要文法
(4) 補足

注意:
- 補足では日本語話者には分かりづらい表現や慣例表現などの解説を加える
- スラッシュリーディングで区切る際「M (修飾語)」が連なって長くなりすぎる場合には5~7語程度を目安に分ける
- 「M」の種類として形容詞、副詞、形容詞句、副詞句、分詞句、形容詞節、副詞節がある

(2) の出力例:
They / elected / him / president / in 2020.
- They: (S) 彼らは
- elected: (V) 選出した
- him: (O) 彼を
- president: (C) 大統領
- in 2020: (M: 副詞句) 2020年に
`

            },
        ]
    },
    {
        "submenu_title": "プログラミング支援",
        "submenu": [
            {
                "title": "コードを解説する",
                "prompt": "以下のコードを初めて読む人も理解できるように解説してください。私には言語の基本理解はあるものとし、コードの意図を理解するための情報を中心に説明してください。"
            },
            {
                "title": "コードを洗練させる",
                "prompt": "以下のコードを洗練させてください"
            },
            {
                "title": "コードをレビューする",
                "prompt": "以下のコードをセキュリティと保守性の観点でレビューしてください"
            },
            {
                "title": "エラーログをデバッグする",
                "prompt": "以下のエラーログを解析し、考えられる原因と対策を提示してください"
            },
            {
                "title": "SQLクエリを最適化する",
                "prompt": "以下のSQL文を最適化してください"
            },
            {
                "title": "PowerShell スクリプトを書く",
                "prompt": "あなたはエキスパートとしてメンテナンス性の高いコードを書くのが得意です。以下の処理を行うPowerShellスクリプトを生成してください"
            },
            {
                "title": "Shell Script を書く",
                "prompt": "あなたはエキスパートとしてメンテナンス性の高いコードを書くのが得意です。以下の処理を行うShell Scriptを生成してください"
            },
        ]
    },
    {
        "submenu_title": "ビジネス文書支援",
        "submenu": [
            {
                "title": "音声文字起こし文を整形する",
                "prompt": `以下は、音声文字起こしの文章です。以下のルールに従って、文章を整形してください。

整形ルール:
- 要約するなどといった文章の全体感を損なう修正はしない
- 会話の流れから不自然と思われる単語には、対象単語の直後に誤字の可能性を示唆する [*] を付ける
- 誤字の可能性を示唆する [*] が付与された場合のみ、出力の末尾に「[*]: 誤字の可能性ありと凡例を書き添える
- 助詞や句読点など文章上の不適合を正す
- フィラーワードを除外する`
            },
            {
                "title": "誤字脱字、不正確な表現をチェック",
                "prompt": "以下の2つのカテゴリーで問題点を指摘してください\n"
                    + "- 【誤字脱字】:明確な誤字脱字および誤字の疑いがある箇所\n"
                    + "- 【不正確・曖昧な表現】:解釈に個人差が生じる可能性がある表現、より適切な表現に改善できる箇所"
            },
            {
                "title": "提案書を作成する",
                "prompt": "以下に対する対する提案書を、コスト・実現性・リスクを含めて作成してください"
            },
            {
                "title": "手順書のアウトラインを作成する",
                "prompt": "以下の作業を行う手順書を、前提条件と注意事項を含めてアウトラインを作成してください"
            },
            {
                "title": "ユーザーマニュアルのアウトラインを作成する",
                "prompt": "以下の機能内容から、ユーザー向けマニュアルのアウトラインを作成してください"
            },
            {
                "title": "議事録を作成する",
                "prompt": "以下の会議内容から議事録を作成してください"
            },
        ]
    },
    {
        "submenu_title": "分析支援",
        "submenu": [
            {
                "title": "問題を分析し、課題を抽出する",
                "prompt": "以下の状況から問題や潜在的リスクを分析し、課題を抽出してください"
            },
            {
                "title": "対応策を提案する",
                "prompt": "以下の問題・課題に対する対応策を複数提案してください"
            },
            {
                "title": "傾向を見つける",
                "prompt": "以下の内容から、数値や挙動上の傾向を見つけてください"
            },
        ]
    },
    {
        "submenu_title": "アイデア出し",
        "submenu": [
            {
                "title": "SCAMPER法でアイデア出し",
                "prompt": "以下のテーマについて、SCAMPER法を使って15個のアイデアを出してください。"
            },
            {
                "title": "解決策の案出し",
                "prompt": "以下の課題に対して、技術面、運用面、コスト面から実現可能な解決策を5つずつ提案してください"
            },
            {
                "title": "逆転の発想",
                "prompt": "以下の目標に対して、通常とは逆の視点からアプローチする方法を3つ提案してください"
            },
            {
                "title": "競合分析からの着想",
                "prompt": "以下の内容について、業界における競合他社の特徴を分析し、差別化できる新しいアプローチを提案してください"
            },
            {
                "title": "ユースケースの列挙",
                "prompt": "以下の製品・サービスについて、想定されるユースケースを10個列挙し、それぞれの価値提案を記載してください"
            },
        ]
    },

]

// コンテキストメニュー番号の定義
const EXECUTE_AI = 1;
const RESET_HISTORY = 2;
const SET_CUSTOM_INSTRUCTION = 3;
const REEXECUTE_LAST_TEMPLATE_PROMPT = 4;
const ROMAN_ALPHABET_IME = 5;
const INVALID_MENU = 9;
const TEMPLATE_PROMPT_THRESHOLD = 10;
const TEMPLATE_PROMPT_SUBMENU_THRESHOLD = 1000;

// タグ名の定義
const HISTORY_TAG = "gpt-history";
const HISTORY_SIZE_TAG = "gpt-history-size";
const TEMPORARY_CUSTOM_INSTRUCTION_TAG = "gpt-temporary-custom-instruction";
const PREVIOUS_TEMPLATE_PROMPT_TAG = "gpt-previous-template-prompt";
const SELECTED_TEMPLATE_PROMPT_TAG = "gpt-selected-template-prompt";

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

// console.log に出力する内容を Mery のアウトプットバーに表示する
var console = {
    log: function (str) {
        outputBar.Writeln(str)
    }
}

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

main();

function main() {
    var selectionIsEmpty = sel.IsEmpty

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

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

        // カスタムインストラクションの設定
        if (menuTrack === SET_CUSTOM_INSTRUCTION) {
            configureTemporaryCustomInstruction();
            return;
        }

        break;
    }

    // 読み取り専用の場合はエラー
    if (doc.ReadOnly) {
        alert("ファイルが読み取り専用のため実行できません。");
        return;
    }

    // 直前に実行したプロンプトテンプレートの再実行の場合、menuTrack を書き換え
    if (menuTrack === REEXECUTE_LAST_TEMPLATE_PROMPT) {
        if (doc.Tag.exists(PREVIOUS_TEMPLATE_PROMPT_TAG)) {
            menuTrack = Number(doc.tag[PREVIOUS_TEMPLATE_PROMPT_TAG]);
        } else {
            alert("直前に実行したプロンプトテンプレートがありません。");
            return;
        }
    }

    // テンプレートを取得
    var isTemplate = menuTrack >= TEMPLATE_PROMPT_THRESHOLD;
    var isSubmenu = menuTrack >= TEMPLATE_PROMPT_SUBMENU_THRESHOLD;
    if (isSubmenu) {
        var parentMenuTrack = Number(String(menuTrack).slice(0, 2));
        var subMenuTrack = Number(String(menuTrack).slice(2));
        var templatePrompt = PROMPT_TEMPLATE[parentMenuTrack - TEMPLATE_PROMPT_THRESHOLD].submenu[subMenuTrack].prompt;
    } else if (isTemplate) {
        var templatePrompt = PROMPT_TEMPLATE[menuTrack - TEMPLATE_PROMPT_THRESHOLD].prompt;
    }

    // テンプレートの場合は直前に実行したプロンプトテンプレートの番号としてタグに保存
    if (isTemplate) {
        doc.tag[SELECTED_TEMPLATE_PROMPT_TAG] = templatePrompt;
        doc.tag[PREVIOUS_TEMPLATE_PROMPT_TAG] = menuTrack
    } else {
        // テンプレート以外の場合はタグを削除
        if (doc.Tag.exists(SELECTED_TEMPLATE_PROMPT_TAG)) {
            doc.Tag.remove(SELECTED_TEMPLATE_PROMPT_TAG);
        }
    }

    // ローマ字⇒日本語変換のプロンプトをセット
    const isRomanAlphabetIme = menuTrack === ROMAN_ALPHABET_IME;
    if (isRomanAlphabetIme) {
        doc.tag[SELECTED_TEMPLATE_PROMPT_TAG] = `以下のローマ字で入力された文章を、日本語に変換してください。
制約:
- 原則、入力のままの用語・口調で変換
- 誤字脱字は修正してよい
- 適宜、句読点を入れて読みやすくしてよい
- 返事は不要で、書き直した文章のみを出力`;
    }

    // gpt API 呼び出し部品を実行
    // V8版で fetch メソッドを使うために別呼び出しとする
    doc.tag[TEMPORARY_CUSTOM_INSTRUCTION_TAG] = getTemporaryCustomInstruction() ? getTemporaryCustomInstruction() : DEFAULT_CUSTOM_INSTRUCTION;
    ShowTip("リクエスト中.....", meShowTipPosCaret);
    editor.ExecuteMacro("v8_call_gpt.js");
}

function createContextMenu(selectionIsEmpty) {
    const menu = CreatePopupMenu();
    menu.Add("※注意※ プライバシー・機密情報を送信しないこと", INVALID_MENU);
    menu.Add("", 0, meMenuSeparator);
    let t = "";
    if (selectionIsEmpty) {
        t = "アクティブ行の内容で相談 (&A)";
    } else {
        t = "選択範囲の内容で相談 (&A)";
    }
    menu.Add(t, EXECUTE_AI);
    if (HISTORY_SIZE <= 0) {
        menu.Add("├ 会話履歴をクリア (履歴が無効になっています)", RESET_HISTORY, meMenuGrayed);
    } else if (doc.Tag.exists(HISTORY_TAG)) {
        var historyData = JSON.parse(doc.tag[HISTORY_TAG]);
        var messages;
        if (Array.isArray(historyData)) {
            // 旧形式: 配列が直接 messages
            messages = historyData;
        } else {
            // 新形式: { session_id, messages }
            messages = historyData.messages || [];
        }
        var turn = (messages.length) / 2;
        menu.Add("├ 会話履歴をクリア (現在の会話数: " + turn + ") (&C)", RESET_HISTORY);
    } else {
        menu.Add("├ 会話履歴をクリア (現在の会話数: 0) (&C)", RESET_HISTORY, meMenuGrayed);
    }
    menu.Add("└ カスタムインストラクションを変更 (&I)", SET_CUSTOM_INSTRUCTION);

    menu.Add("ローマ字⇒日本語 変換 (&R)", ROMAN_ALPHABET_IME);

    // テンプレート数チェック
    // 2桁まで許容
    const ITEMS_MUX = 99;
    if (PROMPT_TEMPLATE.length > ITEMS_MUX - TEMPLATE_PROMPT_THRESHOLD) {
        throw new Error("プロンプトテンプレートの数が多すぎます。");
    }

    // テンプレートメニューの追加
    if (PROMPT_TEMPLATE.length > 0) {
        const subMenu = CreatePopupMenu();
        let previousMenuString = "";
        for (let i = 0; i < PROMPT_TEMPLATE.length; i++) {
            let accelerationKey = "";
            if (i === 9) {
                accelerationKey = " (&0)";
            } else if (i < 10) {
                accelerationKey = " (&" + (i + 1) + ")";
            }
            const menuNum = TEMPLATE_PROMPT_THRESHOLD + i;
            if (PROMPT_TEMPLATE[i].submenu) {
                const subMenuList = PROMPT_TEMPLATE[i].submenu;
                // テンプレート数チェック
                if (subMenuList.length > ITEMS_MUX) {
                    throw new Error("プロンプトテンプレート (サブメニュー) の数が多すぎます。");
                }
                const subSubMenu = CreatePopupMenu();
                const subMenuPrefix = String(menuNum);
                for (let j = 0; j < subMenuList.length; j++) {
                    let subAccelerationKey = "";
                    if (j === 9) {
                        subAccelerationKey = " (&0)";
                    } else if (j < 10) {
                        subAccelerationKey = " (&" + (j + 1) + ")";
                    }
                    // ES5 compatible version without padStart
                    const subMenuNum = Number(subMenuPrefix + (j < 10 ? "0" + j : String(j)));
                    subSubMenu.Add(subMenuList[j].title + subAccelerationKey, subMenuNum);
                    if (subMenuNum === doc.tag[PREVIOUS_TEMPLATE_PROMPT_TAG]) {
                        previousMenuString = subMenuList[j].title;
                    }
                }
                subMenu.AddPopup(PROMPT_TEMPLATE[i].submenu_title + accelerationKey, subSubMenu);
            } else {
                subMenu.Add(PROMPT_TEMPLATE[i].title + accelerationKey, menuNum);
            }
        }
        if (selectionIsEmpty) {
            t = "プロンプト テンプレート (対象: アクティブ行) (&T)";
        } else {
            t = "プロンプト テンプレート (対象: 選択範囲) (&T)";
        }
        menu.AddPopup(t, subMenu);

        t = "└ 直前に実行したプロンプト テンプレートを再実行 (&R)";
        if (doc.Tag.exists(PREVIOUS_TEMPLATE_PROMPT_TAG)) {
            menu.Add(t, REEXECUTE_LAST_TEMPLATE_PROMPT);
            menu.Add(`    (直前実行: ${previousMenuString})`, REEXECUTE_LAST_TEMPLATE_PROMPT);
        } else {
            menu.Add(t, REEXECUTE_LAST_TEMPLATE_PROMPT, meMenuGrayed);
        }
    }

    return menu;
}

function configureTemporaryCustomInstruction() {
    let tempCustomInstruction = getTemporaryCustomInstruction();
    const prompt = Prompt("カスタムインストラクションを変更 ※変更すると会話履歴がリセットされます", tempCustomInstruction ? tempCustomInstruction : DEFAULT_CUSTOM_INSTRUCTION);
    if (prompt !== null) {
        if (prompt === DEFAULT_CUSTOM_INSTRUCTION) {
            // デフォルトと同じ値の場合はタグを削除
            setTemporaryCustomInstruction("");
        } else if (prompt === "" && Confirm("カスタムインストラクションをデフォルトに戻しますか?")) {
            setTemporaryCustomInstruction("");
        } else if (prompt !== tempCustomInstruction) {
            // デフォルト以外の値が設定された場合は保存
            setTemporaryCustomInstruction(prompt);
        }
    }

    // 変更が加わっている場合は会話履歴をリセット
    if (tempCustomInstruction !== getTemporaryCustomInstruction()) {
        resetTag();
    }
}

function resetTag() {
    const tag = doc.Tag;
    if (tag.exists(HISTORY_TAG)) {
        tag.remove(HISTORY_TAG);
    }
}

function getTemporaryCustomInstruction() {
    var tag = doc.Tag;
    if (tag.exists(TEMPORARY_CUSTOM_INSTRUCTION_TAG)) {
        return tag[TEMPORARY_CUSTOM_INSTRUCTION_TAG];
    } else {
        return "";
    }
}

function setTemporaryCustomInstruction(temporaryCustomInstruction) {
    var tag = doc.Tag;
    if (temporaryCustomInstruction) {
        tag[TEMPORARY_CUSTOM_INSTRUCTION_TAG] = temporaryCustomInstruction;
    } else {
        tag.remove(TEMPORARY_CUSTOM_INSTRUCTION_TAG);
    }
}

v8_call_gpt.js[編集]

#language = "v8"
#title = "GPT に相談 (API 呼び出し部品)"
BeginUndoGroup();

// 生成AIの回答の言語設定
// "Japanese" や "English" を設定できます。他の言語は未検証。
// 自動設定にする場合は空文字 "" を設定してください。
const LANGUAGE = "Japanese";

// 入力プロンプトの最大トークン数
// 超適当なトークナイザー関数により計測し、英単語は4文字1トークン、それ以外は1文字1トークンとして数えます。
const MAX_PROMPT_TOKEN = 8000;

// 会話履歴に保持する最大トークン数
// 会話履歴のトークン数がこの数を超える場合、古い履歴から削除されます。
const MAX_HISTORY_TOKEN = 50000;

// 生成AI応答の入力毎の遅延時間 (ミリ秒)
// 入力をゆっくりにしたいときは数字を大きくしてください。
const INPUT_DELAY = 20;

// タグ名の定義
const HISTORY_TAG = "gpt-history";
const HISTORY_SIZE_TAG = "gpt-history-size";
const TEMPORARY_CUSTOM_INSTRUCTION_TAG = "gpt-temporary-custom-instruction";
const SELECTED_TEMPLATE_PROMPT_TAG = "gpt-selected-template-prompt";

// 会話の履歴を保持する数 (質問と回答で1セット)
// boot マクロ側で設定されていることを前提としている
if (!document.tag.exists(HISTORY_SIZE_TAG)) {
    throw new Error("会話履歴サイズが設定されていません。");
}
const HISTORY_SIZE = document.tag[HISTORY_SIZE_TAG];

// プロンプトキャッシュの照合に使う安定したキー
const PROMPT_CACHE_KEY = "mery-gpt-call-v1";

// 生成AIのモデル名

const MODEL_CONFIG = {
    model: "gpt-5.6-terra",
    reasoning: { effort: "medium" },
    max_output_tokens: 4000,
}

// console.log に出力する内容を Mery のアウトプットバーに表示する
const console = {
    log: function (str) {
        outputBar.Writeln(str)
    }
}

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

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

main();

async function main() {
    let promptText = sel.Text.trim();

    // プロンプトが空の場合はエラー
    if (promptText.length === 0) {
        alert("プロンプトを入力してください。");
        return;
    }
    // 文字数が多すぎる場合はエラー
    if (roughTokenize(promptText) > MAX_PROMPT_TOKEN) {
        alert("プロンプトの文字数が多すぎます。文字数を少なくしてください。");
        return;
    }


    // テンプレートの場合はプロンプトを変更
    const isTemplate = doc.Tag.exists(SELECTED_TEMPLATE_PROMPT_TAG);
    if (isTemplate) {
        let instructionText = doc.Tag[SELECTED_TEMPLATE_PROMPT_TAG];
        promptText = `
以下の <prompt_set> 内の <instructions> の指示に従って、 <context> を処理してください。

<prompt_set>
<instructions>
${instructionText}
</instructions>

<context>
${promptText}
</context>
</prompt_set>
        `.trim();
    }

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

        // カーソル位置移動
        sel.SetActivePoint(mePosLogical, sel.GetBottomPointX(mePosLogical), sel.GetBottomPointY(mePosLogical));
        sel.EndOfLine(false, mePosLogical);
        sel.NewLine();
        sel.Text = "\n---\n\n"

        document.KeepScrollPos = true;

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

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

// Shift キーが長押しされた場合はリクエスト中止フラグを立てる
async function abortRequest() {
    const VIRTUAL_KEY_CODE_SHIFT = 0x10; // Shift キーの仮想キーコード
    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 callApi(apiKey, promptText, isTemplate) {
    const controller = new AbortController();
    const signal = controller.signal;
    try {
        // Responses API にストリーミングでリクエスト

        const customInstructionText = buildCustomInstructionText();
        const historyData = isTemplate ? null : getHistoryData();
        // 会話中は最初のターンで保存した instructions を継続して使用する
        const instructionText = getHistoryInstruction(historyData, customInstructionText);
        const requestInput = createRequestInput(promptText, isTemplate, historyData);
        const requestBody = {
            input: requestInput,
            instructions: instructionText,
            prompt_cache_key: PROMPT_CACHE_KEY,
            stream: true,
        };

        for (let key in MODEL_CONFIG) {
            requestBody[key] = MODEL_CONFIG[key];
        }
        const url = 'https://api.openai.com/v1/responses';

        const apiCallStartTime = Date.now();
        const response = await fetch(url, {
            signal: signal,
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + apiKey,
            },
            body: JSON.stringify(requestBody),
        });
        if (!response.ok) {
            let errorMessage = response.status + " " + response.statusText;
            try {
                const errorData = await response.json();
                errorMessage = errorData?.error?.message || errorMessage;
            } catch (_) {
                // エラー本文が JSON でない場合は HTTP ステータスを使用する
            }
            throw new Error("Responses API エラー: " + errorMessage);
        }

        let buffer = "";
        let responseAll = "";
        let usageText = "";
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8');
        while (true) {
            // リクエスト中止フラグが立っている場合は中断
            if (abortRequestFlg) {
                controller.abort();
                sel.Text = "<処理を中止>"
                return;
            }

            const { done, value } = await reader.read();
            if (done) {
                // デコーダーに残った文字列と最後の SSE 行を処理
                const finalText = buffer + decoder.decode();
                let finalInputText = "";
                for (const line of finalText.split(/\n+/)) {
                    const processedData = processLine(line, responseAll);
                    if (processedData) {
                        finalInputText += processedData.inputText || "";
                        usageText = processedData.usageText || usageText;
                        responseAll = processedData.responseAll || responseAll;
                    }
                }
                if (finalInputText) {
                    sel.Text = finalInputText;
                }
                break;
            }

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

            // 前回のバッファと今回のテキストを結合
            const combinedText = buffer + text;

            // 完全な行を探す(最後の改行の位置を見つける)
            const lastNewlineIndex = combinedText.lastIndexOf('\n');

            if (lastNewlineIndex === -1) {
                // 改行がない場合は全てバッファに保存
                buffer = combinedText;
                continue;
            }

            // 完全な行を処理
            const completeText = combinedText.substring(0, lastNewlineIndex);
            // 残りをバッファに保存
            buffer = combinedText.substring(lastNewlineIndex + 1);

            // 完全な行を処理
            const lines = completeText.split(/\n+/);

            let inputText = "";
            for (const line of lines) {
                // 行の処理(既存のコードと同様)
                const processedData = processLine(line, responseAll);
                if (processedData) {
                    inputText += processedData.inputText || "";
                    usageText = processedData.usageText || usageText;
                    responseAll = processedData.responseAll || responseAll;
                }
            }

            if (inputText.length > 0) {
                // 上記までで数文字まとまって取得されるので、まとめて入力
                sel.Text = inputText;
            }
            // 入力を少しゆっくりにする
            await new Promise(resolve => setTimeout(resolve, INPUT_DELAY));
        }

        // トークン数・所要時間を入力
        const elapsedSec = ((Date.now() - apiCallStartTime) / 1000).toFixed(1);
        const elapsedText = "[所要時間] " + elapsedSec + " sec";
        if (usageText) {
            usageText += " " + elapsedText;
        } else {
            usageText = elapsedText;
        }
        sel.Text = (responseAll.slice(-1) === "\n" ? "\n" : "\n\n") + `${usageText}`;

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

// Responses API の SSE イベントを一行ずつ処理する
function processLine(line, responseAll) {
    // HACK 万が一空行が入ってきた場合に備えて trim() しておく
    const json_text = line.trim().replace(/^data:\s*/, '');
    // データがない場合はスキップ
    if (!json_text) {
        return null;
    }
    // SSE コメント行 (: で始まる行) はスキップ
    if (json_text.startsWith(':')) {
        return null;
    }
    // SSE の event: 行はイベント名だけを表し、データ本体ではない
    if (json_text.startsWith('event:')) {
        return null;
    }
    // データが終了した場合は終了
    if (json_text === '[DONE]') {
        return null;
    }

    let data;
    try {
        data = JSON.parse(json_text);
    } catch (e) {
        console.log("JSONパースエラー: " + e.message + ", データ: " + json_text);
        return null;
    }

    if (data.type === "error") {
        throw new Error(data.error?.message || "Responses API ストリームエラー");
    }

    try {
        let usageText = "";
        let inputText = "";
        let updatedResponseAll = responseAll;

        // 利用量は response.completed イベント内の response.usage に含まれる
        const usage = data.response?.usage || data.usage;
        if (usage) {
            usageText = "[利用トークン数] 入力:" + usage.input_tokens
                + " 出力:" + usage.output_tokens
                + " 合計:" + usage.total_tokens;
        }

        // テキストは response.output_text.delta イベントで届く
        if (data.type === "response.output_text.delta" && data.delta) {
            inputText = data.delta;
            updatedResponseAll += data.delta;
        }
        return { inputText, usageText, responseAll: updatedResponseAll };
    } catch (e) {
        console.log("Responses API イベント処理エラー: " + e.message + ", データ: " + json_text);
        return null;
    }
}

function roughTokenize(text) {
    // 超適当なトークナイザ。トークン数をざっくりと多めに数える
    // 空白文字・ハイフン・アンダースコア区切りの単語に分割して、
    // 単語がアルファベットのみ、数字のみの場合には 4文字=1トークン として数える。
    // 単語にアルファベット以外の文字が含まれる場合は 1文字=1トークン として数える。
    return text
        .split(/[\s\-_]+/)
        .map(w => /^[a-zA-Z]+$|^[0-9]+$|/.test(w) ? Math.ceil(w.length / 4) : w.length)
        .reduce((a, b) => a + b, 0);
}

// document.Tag に保存された会話履歴を取得する
function getHistoryData() {
    const tag = document.Tag;
    return tag.exists(HISTORY_TAG) ? JSON.parse(tag[HISTORY_TAG]) : null;
}

// 保存済みの instructions を取得する
function getHistoryInstruction(historyData, fallbackInstruction) {
    return historyData ? historyData.instruction : fallbackInstruction;
}

// タグの messages を取得する
function extractHistoryTexts(historyData) {
    return historyData ? historyData.messages : [];
}

// document.Tag の内容を加味して Responses API の input を作成
function createRequestInput(promptText, isTemplate, historyData) {
    // テンプレートの場合は会話履歴を反映しない
    if (isTemplate) {
        return [{ "role": "user", "content": promptText }];
    }

    // 以前の会話が存在しない場合は新規リクエスト
    if (!historyData) {
        return [{ "role": "user", "content": promptText }];
    }

    // 会話履歴が存在する場合は、履歴を含めてリクエスト
    const input = extractHistoryTexts(historyData)
        .map((message, index) => ({
            role: index % 2 === 0 ? "user" : "assistant",
            content: message
        }));
    input.push({
        "role": "user",
        "content": promptText
    });
    return input;
}

// document.Tag にgptの応答を追加
function appendReturnTextToTag(userText, modelText, instructionText) {
    const tag = document.Tag;
    const messages = extractHistoryTexts(getHistoryData());

    messages.push(userText, modelText);
    // 会話セット (user, model 1往復) が特定セット数を超えた場合、最初のセットを削除
    if (messages.length > HISTORY_SIZE * 2) {
        messages.splice(0, 2);
    }
    // 会話履歴全体が MAX_HISTORY_TOKEN を超えた場合、最初のセットを削除
    let joinedText = messages.join("\n");
    while (messages.length >= 2 && roughTokenize(joinedText) > MAX_HISTORY_TOKEN) {
        messages.splice(0, 2);
        joinedText = messages.join("\n");
    }

    tag[HISTORY_TAG] = JSON.stringify({
        instruction: instructionText,
        messages: messages
    });
}

function getLocalIsoDateTimeString() {
    const now = new Date();
    const pad = function (num) {
        return (num < 10 ? "0" : "") + num;
    };

    const year = now.getFullYear();
    const month = pad(now.getMonth() + 1);
    const day = pad(now.getDate());
    const hours = pad(now.getHours());
    const minutes = pad(now.getMinutes());
    const seconds = pad(now.getSeconds());

    const offsetMinutes = now.getTimezoneOffset();
    const offsetSign = offsetMinutes <= 0 ? "+" : "-";
    const absOffsetHours = pad(Math.floor(Math.abs(offsetMinutes) / 60));
    const absOffsetMinutes = pad(Math.abs(offsetMinutes) % 60);

    const isoLikeLocal = year + "-" + month + "-" + day
        + "T" + hours + ":" + minutes + ":" + seconds
        + offsetSign + absOffsetHours + ":" + absOffsetMinutes;
    return isoLikeLocal;
}

function baseCustomInstruction() {
    const language = LANGUAGE.trim();
    return "The current date and time for this user is " + getLocalIsoDateTimeString() + ". You cannot know for sure what happens after the knowledge cut-off date."
        + (language ? "Respond in " + language + "\n" : "")
        + "Do not hallucinate.\n"
        + "**Start with the main point of the question without explaining or declaring your writing style or tone.**\n"
        + "**At the end of your answer, avoid adding formulaic phrases that prompt the next question or unrelated conversation that is not directly related to your answer.**\n"
    // + "Do NOT EVER use full-width brackets \"()\", but use half-width brackets \"()\". Enclose the outside of the half-width brackets with half-width spaces.\n"
    // + "When using Markdown formatting, you MUST follow these rules:\n"
    // + "- Rule 1: Insert a blank line below the header line (A header line begins with one or more # characters).\n"
    // + "- Rule 2: Insert spaces before and after bold (surrounded by **), italic (surrounded by *), and inline code (surrounded by `). For example: \"私は **元気** です。\"\n"
}

function buildCustomInstructionText() {
    let customInstruction = getTemporaryCustomInstruction();
    if (!customInstruction) {
        // 一時的なカスタムインストラクションがない場合はデフォルトを使用
        customInstruction = DEFAULT_CUSTOM_INSTRUCTION;
    }

    customInstruction = customInstruction.replace(/^[\s ]+|[\s ]+$/g, "");

    return baseCustomInstruction().trim()
        + (customInstruction ? "\n" + customInstruction : "");
}

function getTemporaryCustomInstruction() {
    var tag = doc.Tag;
    if (tag.exists(TEMPORARY_CUSTOM_INSTRUCTION_TAG)) {
        return tag[TEMPORARY_CUSTOM_INSTRUCTION_TAG];
    } else {
        return "";
    }
}

function generateRandomId(length = 16) {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let result = '';
    for (let i = 0; i < length; i++) {
        result += chars[Math.floor(Math.random() * chars.length)];
    }
    return result;
}

従来エンジン版 (アーカイブ)[編集]

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

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

// コンテキストメニュー番号
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);

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

    // 結果を表示
    sel.Text = "\n---\n\n" + ret[0] + "\n\n" + ret[1] + "\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("gpt-text")) {
        var turn = JSON.parse(document.Tag("gpt-text")).length / 2;
        menu.Add("会話履歴をリセット (現在の会話数: " + turn + ") (&R)", RESET_HISTORY);
    }
    return menu;
}

function callGPTAPI(promptText) {
    try {
        // APIキーの取得
        var gptApiKey = shell.getEnv("OPENAI_API_KEY");

        var http = new ActiveXObject("Msxml2.ServerXMLHTTP");
        var url = 'https://api.openai.com/v1/chat/completions';

        http.open('POST', url, true);
        http.setRequestHeader('Content-Type', 'application/json');
        http.setRequestHeader('Authorization', 'Bearer ' + gptApiKey);
        http.send('{'
            + '"model": "gpt-4o-mini",' // モデルの指定
            + '"temperature": 0.7,' // 生成の多様性 (0.0 ~ 1.0)
            + '"max_tokens": 4000,' // 生成トークン数の上限
            + '"messages":' + createGPTRequest(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 responseMessage = "";
        var token = "";

        // 応答ステータスチェック
        if (http.readyState == 4 && http.status == 200) {
            // JSONをパースして必要なテキストを抽出
            var response = JSON.parse(http.responseText);
            if (response.choices && response.choices.length > 0) {
                // メッセージの内容を取得
                response.choices.forEach(function (choice) {
                    if (choice.message && choice.message.content) {
                        responseMessage += choice.message.content;
                    }
                });
                // トークン数を取得
                var usage = response.usage;
                token += "(使用トークン数: "
                    + "プロンプト=" + usage.prompt_tokens
                    + ", 応答=" + usage.completion_tokens
                    + ", 合計=" + usage.total_tokens
                    + ")";
            }
        } else {
            var responseMessage = "エラーが発生しました: " + http.status + " " + http.statusText
            http.abort();
            return responseMessage;
        }

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

        // 整形しつつ返す
        return [responseMessage.trim(), token];
    } catch (e) {
        console.log(e.stack);
        return ["エラーが発生しました: " + e.message, ""];
    }
}

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

    var gptText = JSON.parse(tag("gpt-text"));
    gptText.push({
        "role": "user",
        "content": promptText
    });
    return JSON.stringify(gptText);
}

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

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

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

更新履歴[編集]

  • 2026-07-18 起動時間短縮のため、マクロ起動とAPI呼び出しで、マクロを分割した。その他プロンプトテンプレート更新等。
  • 2025-02-16 カスタムインストラクションにハルシネーション抑制の指示を加えた。プロンプトに渡す文字数制限方式を調整。
  • 2024-12-22 V8エンジン版を追加。それに伴い従来エンジン版はアーカイブ化。
  • 2024-12-18 初版
スポンサーリンク