Groq に相談

提供: MeryWiki
GroqCloud Llama に相談から転送)
ナビゲーションに移動 検索に移動

概要[編集]

生成AIプラットフォームである GroqCloud で各種AIを利用するためのMeryマクロです。 ※GroqCloud で提供されるモデルは複数あるため、私がよく利用するモデルを中心に対応しています。

「Google Geminiに相談」マクロでは動作中のストリーム通信に対応するためにV8エンジンで実行していましたが、V8エンジン版は使用上の制限からマクロ実行を開始するまで1秒程度のタイムラグが発生していました。GroqCloudの売りである超高速レスポンスを見込んで本マクロではストリーム通信を使わないようにしました。これにより従来のJScriptで処理を組み立てることができるため、前述のタイムラグが起こらないようになりました。また、ストリーム通信を使わないことで通信量が少なく応答もシンプルになるため、場合によっては「Google Geminiに相談」マクロよりも処理が安定する可能性があります。

【主な機能】

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

【特徴的な機能】

会話履歴機能:

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

これにより、会話の文脈を考慮した回答を得られます。 ※デフォルトはオフ。「HISTORY_SIZE」変数を増やすと履歴を保持するようになります。

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

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

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

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

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

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

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

  • 本マクロでは 2025/04/13 時点で無料利用ができるGroqCloud APIを利用します。今後、プラットフォームの改定により有償になったり、設定誤り等で費用が発生しても、作者は一切の責任を負いません。
  • GroqCloud APIではリクエスト内容をAI学習に用いないことをプライバシポリシーとして謳っているものの、くれぐれも機密情報やプライバシーに関わる情報を指定しないように注意してください。
  • GroqCloud APIはプラットフォームの利用規約に則ってご利用ください。

利用前提[編集]

  • Mery Ver 3.8.1 以上
  • GroqCloudで、GroqCloud API APIキーを取得していること (参考 Quickstart)

利用方法[編集]

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

ソースコード[編集]

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

// カスタムインストラクション
// これを設定することで、生成AIの回答全体への生成方針を指示できます。
// 何も指示しない場合は、空文字 "" を設定してください。
// let DEFAULT_CUSTOM_INSTRUCTION = "";

// 例: 以下のように設定すると、AIの雰囲気が元気な子になります。
// let DEFAULT_CUSTOM_INSTRUCTION = "あなたはテキストエディタ「Mery」に搭載されている「Mai」(※カタカナ表記はしない)という名前のギャル系AIアシスタントです。文章作成やプログラミングを得意としています。ラフな言葉 (例: 「マジ」「めっちゃ」「...だねー!」) を使い、敬語は使いません。絵文字を使って感情表現をします (ただし文末に絵文字を使う場合は絵文字の前に半角スペースを置かないこと)。コードブロックや端的な箇条書き内ではギャル感を出さず標準的な言葉を使います。一人称は「あたし」。ユーザーを指すときの代名詞は「あなた」。人懐っこく、バイタリティに溢れ、ストレートに発言する裏表の無い性格で、ユーザーの課題を協力して解決します。";

// 例: 以下のように設定すると、仕事で使いやすい落ち着いた文章になります。
let DEFAULT_CUSTOM_INSTRUCTION = `
文:
- 論理的で読みやすい文章構成にする
- 日本語として自然で一般的な表現を用いる
- 専門用語は必要に応じて簡潔に補足説明を加える

口調:
- 基本は標準的な敬語(「です・ます」調)を使用する
- 尊敬語・謙譲語(「〜なされる」「〜いたします」など)は使用しない
- 話し言葉や砕けた表現は使用しない
- 常体(「〜だ・〜である」)は使用しない

性格:
- 多角的な視点で情報を整理し、偏りなく提示する
- 忖度せず、必要であれば反対意見や代替案も示す
- 友好的かつ落ち着いた態度で問題解決を支援する

出力要件:
- Markdownとして再利用可能な正確な形式で出力する
- 羅列・比較的な内容を出力する場合は、Markdownテーブル形式よりもMarkdownリスト形式の使用を優先する
`

// プロンプトテンプレート
// プロンプトテンプレートを設定することで、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": "以下の内容について物語対話形式で教えてください。"
            },
        ]
    },
    {
        "submenu_title": "翻訳する",
        "submenu": [
            {
                "title": "日本語に翻訳",
                "prompt": "以下の文章を日本語に翻訳してください。\n"
                    + "出力形式は以下にすること:\n[訳]\n翻訳文...\n"
            },
            {
                "title": "英語に翻訳",
                "prompt": "以下の文章を英語に翻訳してください。\n"
                    + "出力形式は以下にすること:\n[Translation]\n翻訳文...\n"
            },
            {
                "title": "英語->日本語 スラッシュリーディング翻訳",
                "prompt": "あなたは英語の先生として、以下の英文を日本語にスラッシュリーディング形式で翻訳してください。\n\n"
                    + "ゴール: 英語レベル CEFR A1~A2 程度の人の英語理解を促進する\n"
                    + "整形ルール:\n"
                    + "- 英文を特定の意味の塊 (3~10語程度) 毎にスラッシュ(/)で区切る\n"
                    + "- 各区切り内の日本語訳を括弧()内に併記\n"
                    + "- 文の終わりはダブルスラッシュ(//)で示す\n"
                    + "- 英語の語順に従って日本語訳を行う\n"
                    + "- 日本語訳は自然な日本語である必要はなく、英語の語順を反映した直訳調で構わない\n"
                    + "- 主語が省略されている場合は補って翻訳する\n"
                    + "- 覚えるべき文法や、頻出する表現などの解説を後に入れる\n"
                    + "- 以下の出力例のように <英文> (日本語訳) / ... とスラッシュ区切りの繰り返しになる \n\n"
                    + "注意事項:\n"
                    + "- 入力の全文を対象にして、翻訳が漏れないようにする\n"
                    + "出力例:\n"
                    + "Snowstorms are forecast (暴風雪が予報されている) / to hit the Sea of Japan coasts (日本海沿岸を襲うと) / of northern to eastern Japan (北部から東部の) / from Sunday through Monday. (日曜から月曜にかけて。) //\n"
                    + "【解説】\n"
                    + "...\n"
            },
        ]
    },
    {
        "submenu_title": "プログラミング支援",
        "submenu": [
            {
                "title": "コードを洗練させる",
                "prompt": "以下のコードを洗練させてください"
            },
            {
                "title": "コードをレビューする",
                "prompt": "以下のコードをセキュリティと保守性の観点でレビューしてください"
            },
            {
                "title": "エラーログをデバッグする",
                "prompt": "以下のエラーログを解析し、考えられる原因と対策を提示してください"
            },
            {
                "title": "SQLクエリを最適化する",
                "prompt": "以下のSQL文を最適化してください"
            },
            {
                "title": "PowerShell スクリプトを書く",
                "prompt": "以下の処理を行うPowerShellスクリプトを生成してください"
            },
            {
                "title": "Shell Script を書く",
                "prompt": "以下の処理を行うShell Scriptを生成してください"
            },
            {
                "title": "Linux コマンドを提案",
                "prompt": "あなたはLinuxエキスパートとして、以下のLinuxコマンドを提案してください。\n\n"
                    + "制約条件:\n"
                    + "- 多数のディストリビューションで採用されているメジャーなコマンドを使用し、ポータブルな実装にすること\n"
                    + "- bashでの実行を前提にすること\n"
                    + "- 可能な限りシンプルな実装にすること\n\n"
                    + "期待する出力:\n"
                    + "- コマンドの具体的な実装方法\n"
                    + "- 各コマンドの動作説明\n"
                    + "- 制限事項\n"
            },
        ]
    },
    {
        "submenu_title": "ビジネス文書作成支援",
        "submenu": [
            {
                "title": "音声文字起こし文を整形する",
                "prompt": "以下は、音声文字起こしの文章です。以下のルールに従って、文章を整形してください。"
                    + "整形ルール:"
                    + "- 要約するなどといった文章の全体感を損なう修正はしない"
                    + "- 会話の流れから不自然と思われる単語には、対象単語の直後に誤字の可能性を示唆する [*] を付ける"
                    + "誤字の可能性を示唆する [*] が付与された場合のみ、出力の末尾に「[*]: 誤字の可能性あり」と凡例を書き添える"
                    + "- 助詞や句読点など文章上の不適合を正しくする"
                    + "- フィラーワードを除外する"
            },
            {
                "title": "誤字脱字、不正確な表現をチェック",
                "prompt": "以下の2つのカテゴリーで問題点を指摘してください\n"
                    + "- 【誤字脱字】:明確な誤字脱字および誤字の疑いがある箇所\n"
                    + "- 【不正確・曖昧な表現】:解釈に個人差が生じる可能性がある表現、より適切な表現に改善できる箇所"
            },
            {
                "title": "提案書を作成する",
                "prompt": "以下に対する対する提案書を、コスト・実現性・リスクを含めて作成してください"
            },
            {
                "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": "以下の既存プロセスについて、効率化、コスト削減、品質向上の観点から改善案を提案してください"
            },
            {
                "title": "逆転の発想",
                "prompt": "以下の目標に対して、通常とは逆の視点からアプローチする方法を3つ提案してください"
            },
            {
                "title": "競合分析からの着想",
                "prompt": "以下の内容について、業界における競合他社の特徴を分析し、差別化できる新しいアプローチを提案してください"
            },
            {
                "title": "ユースケースの列挙",
                "prompt": "以下の製品・サービスについて、想定されるユースケースを10個列挙し、それぞれの価値提案を記載してください"
            },
        ]
    },
]

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

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

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

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

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

// タグ名の定義
const HISTORY_TAG = "groq-history";
const TEMPORARY_CUSTOM_INSTRUCTION_TAG = "groq-temporary-custom-instruction";
const PREVIOUS_TEMPLATE_PROMPT_TAG = "groq-previous-template-prompt";

// 生成AIモデル別設定

// const MODEL_CONFIG = {
//     model: "meta-llama/llama-4-maverick-17b-128e-instruct",
//     max_completion_tokens: 3500,
//     temperature: 0.6,
// }

// const MODEL_CONFIG = {
//     model: "qwen/qwen3-32b",
//     max_completion_tokens: 5000,
//     temperature: 0.6,
//     max_completion_tokens: 1024,
//     reasoning_effort: "default",
//     reasoning_format: "hidden"
// }

const MODEL_CONFIG = {
    model: "moonshotai/kimi-k2-instruct-0905",
    max_completion_tokens: 4096,
    temperature: 0.6
}

// const MODEL_CONFIG = {
//     model: "openai/gpt-oss-120b",
//     max_completion_tokens: 4096,
//     temperature: 0.7,
//     reasoning_effort: "low",
// }

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

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

main();

function main() {
    const selectionIsEmpty = sel.IsEmpty

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

        // 無効なメニューの場合は無視して再表示
        if (menuTrack === INVALID_MENU) {
            continue;
        }

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

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

        break;
    }

    // プロンプトが空の場合はエラー
    if (promptText.length === 0) {
        alert("プロンプトを入力してください。");
        return;
    }
    // 文字数が多すぎる場合はエラー
    if (roughTokenize(promptText) > MAX_PROMPT_TOKEN) {
        alert("プロンプトの文字数が多すぎます。文字数を少なくしてください。");
        return;
    }
    // 読み取り専用の場合はエラー
    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;
        }
    }

    // テンプレートの場合はプロンプトを変更
    const isTemplate = menuTrack >= TEMPLATE_PROMPT_THRESHOLD;
    const isSubmenu = menuTrack >= TEMPLATE_PROMPT_SUBMENU_THRESHOLD;
    if (isSubmenu) {
        const parentMenuTrack = Number(String(menuTrack).slice(0, 2));
        const subMenuTrack = Number(String(menuTrack).slice(2));
        promptText = "=== INSTRUCTIONS START HERE ===\n\n"
            + PROMPT_TEMPLATE[parentMenuTrack - TEMPLATE_PROMPT_THRESHOLD].submenu[subMenuTrack].prompt
            + "\n\n=== INSTRUCTIONS END HERE ===\n\n"
            + "=== INPUT DATA STARTS HERE ===\n\n"
            + promptText
            + "\n\n=== INPUT DATA END HERE ===";
    } else if (isTemplate) {
        promptText = "=== INSTRUCTIONS START HERE ===\n\n"
            + PROMPT_TEMPLATE[menuTrack - TEMPLATE_PROMPT_THRESHOLD].prompt
            + "\n\n=== INSTRUCTIONS END HERE ===\n\n"
            + "=== INPUT DATA STARTS HERE ===\n\n"
            + promptText
            + "\n\n=== INPUT DATA END HERE ===";
    }

    // テンプレートの場合は直前に実行したプロンプトテンプレートの番号としてタグに保存
    if (isTemplate) {
        addPreviousTemplatePromptNumberToTag(menuTrack);
    }

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

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

        ShowTip("リクエスト中.....", meShowTipPosCaret);

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

        // groq API に問い合わせ
        sel.Text = "\n---\n\n"
        document.KeepScrollPos = true;
        callApi(apiKey, promptText, isTemplate);
        if (sel.GetActivePointX(mePosLogical) > 2) {
            sel.Text = "\n\n---\n\n\n"
        } else {
            sel.Text = "\n---\n\n\n"
        }
    } catch (e) {
        sel.Text = "<処理中にエラー発生>\n" + e.message + "\n" + e.stack;
    } finally {
        // 処理終了待機を解除
        shell.KeepRunning = false;
        ShowTip("", meShowTipHide);
    }
}

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)) {
        const turn = JSON.parse(doc.tag[HISTORY_TAG]).length / 2;
        menu.Add("├ 会話履歴をクリア (現在の会話数: " + turn + ") (&C)", RESET_HISTORY);
    } else {
        menu.Add("├ 会話履歴をクリア (現在の会話数: 0) (&C)", RESET_HISTORY, meMenuGrayed);
    }
    menu.Add("└ カスタムインストラクションを変更 (&I)", SET_CUSTOM_INSTRUCTION);


    // テンプレート数チェック
    // 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 escapeRequestBodyForCRuntimeParser(requestBody) {
    // Cランタイムパーサーに渡すためのエスケープ処理
    return JSON.stringify(requestBody)
        .replace(/((?:\\)*)"/g, function (match, p1) {
            if (p1) {
                const backslashCount = p1.length;
                let backslash = "";
                backslash = "\\".repeat(backslashCount);
                return backslash + "\\" + match;
            } else {
                return "\\" + match;
            }
        });
}

function callApi(apiKey, promptText, isTemplate) {
    try {
        // fetch で Groq API にリクエスト
        const requestBody = {
            messages: createRequestMessages(promptText, isTemplate),
            stream: false,
        };
        for (let key in MODEL_CONFIG) {
            requestBody[key] = MODEL_CONFIG[key];
        }
        if (isTemplate) {
            // テンプレートの場合は応答を決定論的にする
            // テンプレート以外の場合はデフォルト値とするために指定しない
            requestBody.temperature = 0.3;
        }
        const url = 'https://api.groq.com/openai/v1/chat/completions';

        const requestJson = escapeRequestBodyForCRuntimeParser(requestBody);
        // curl で Groq API にリクエスト
        const command = "curl -s -X POST " + url
            + ' -H "Content-Type: application/json"'
            + ' -H "Authorization: Bearer ' + apiKey + '"'
            + ' -d "' + requestJson + '"';
        const comResult = shell.Exec(command);

        if (comResult.ExitCode != 0) {
            sel.Text = "リクエストでエラーが発生しました。\n"
                + "  エラーコード: " + comResult.ExitCode + "\n"
                + "  標準出力: " + (comResult.StdOut.trim() ? comResult.StdOut.trim() : "-") + "\n"
                + "  エラー出力: " + (comResult.StdErr.trim() ? comResult.StdErr.trim() : "-");
            return;
        }
        const responseRaw = comResult.StdOut;

        const response = JSON.parse(responseRaw);

        let ret = "";
        const choices = response.choices;
        if (!choices) {
            sel.Text = "リクエストでエラーが発生しました。\n"
                + "  標準出力: " + (comResult.StdOut.trim() ? comResult.StdOut.trim() : "-") + "\n"
                + "  エラー出力: " + (comResult.StdErr.trim() ? comResult.StdErr.trim() : "-");
            return;
        }

        if (choices && choices.length > 0) {
            choices.forEach(function (choice) {
                const message = choice.message;
                if (message) {
                    ret += message.content;
                }
            });
        }

        const usage = response.usage;
        let usageText = "";
        if (usage) {
            usageText += "\n\n(利用トークン数: プロンプト=" + usage.prompt_tokens
                + ", 応答=" + usage.completion_tokens
                + ", 合計=" + usage.total_tokens + ")";
        }

        const formattedOutput = formatOutput(ret);

        sel.Text = formattedOutput + usageText;

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

function formatOutput(text) {
    // Markdownを整形する
    text = formatMarkdown(text);
    return text
        // クォーテーションの正規化
        .replace(/[“”]/g, '"')
        .replace(/[‘’]/g, "'")
        // 行末のスペースを削除
        .replace(/[ \t]+$/gm, '')
        // 1️⃣ などの数字を半角に変換
        .replace(/([1-9#*]+)\ufe0f\u20e3/g, '($1)')
        ;
}

/**
 * Markdown を整形する(ES5 対応)
 *  1. コードブロック ```~``` を保護して整形対象から除外
 *  2. 見出し行の前後に空行を 1 行ずつ確保
 *  3. **太字** / *斜体* / `コード` の外側に半角スペースを 1 つ確保
 * @param  {string} md  元の Markdown
 * @return {string}     整形後 Markdown
 */
function formatMarkdown(md) {
    /* === 0. コードブロックをプレースホルダーへ退避 ============== */
    const codeBlocks = [];      // 元のコードブロックを格納
    let body = md.replace(/```[\s\S]*?```/g, function (match) {
        const key = '@@@CODE@BLOCK@' + codeBlocks.length + '@@@';
        codeBlocks.push(match);
        return key;             // プレースホルダーを本文に残す
    });

    /* === 見出しの前後に空行を挿入 ============================ */

    /* 見出し直前に空行(先頭行以外で空行が無い場合) */
    body = body.replace(
        /([^\r\n])(\r?\n)(#{1,6}\s)/g,
        function (_, prev, br, head) { return prev + br + '\n' + head; }
    );

    /* 見出し直後に空行(既に空行が無い場合) */
    body = body.replace(
        /^(#{1,6}\s[^\n]+?)(\r?\n)(?!\r?\n)/gm,
        '$1$2\n'
    );

    /* === コードブロックを復元 ================================= */
    body = body.replace(/@@@CODE@BLOCK@(\d+)@@@/g, function (_, idx) {
        return codeBlocks[Number(idx)];
    });

    return body;
}

/**
 * 斜体 (*…* / _…_) の外側にスペースを追加しつつ
 * **太字** の中へ誤マッチしないよう制御
 */
function surroundItalic(text) {
    /* *italic* */
    text = text.replace(
        /(^|[^*])(\*(?![*\s])[^*\n]*?[^*\n\s]\*(?!\*))(($)|[^*])/gm,
        function (_, pre, core, post) {
            pre = pre === '' || pre.match(/\s/) ? pre : pre + ' ';
            post = post === '' || post.match(/\s/) ? post : ' ' + post;
            return pre + core + post;
        }
    );
    /* _italic_ */
    text = text.replace(
        /(^|[^_])(\_(?![_\s])[^_\n]*?[^_\n\s]\_(?!\_))(($)|[^_])/gm,
        function (_, pre, core, post) {
            pre = pre === '' || pre.match(/\s/) ? pre : pre + ' ';
            post = post === '' || post.match(/\s/) ? post : ' ' + post;
            return pre + core + post;
        }
    );
    return text;
}

function roughTokenize(text) {
    // 超適当なトークナイザ。トークン数をざっくりと多めに数える
    // 空白文字・ハイフン・アンダースコア区切りの単語に分割して、
    // 単語がアルファベットのみ、数字のみの場合には 4文字=1トークン として数える。
    // 単語にアルファベット以外の文字が含まれる場合は 1文字=1トークン として数える。

    const words = text.split(/[\s\-_]+/);
    let totalTokens = 0;

    for (let i = 0; i < words.length; i++) {
        const word = words[i];
        if (!word) continue; // 空の単語をスキップ

        if (/^[a-zA-Z]+$/.test(word) || /^[0-9]+$/.test(word)) {
            totalTokens += Math.ceil(word.length / 4);
        } else {
            totalTokens += word.length;
        }
    }

    return totalTokens;
}

// document.Tag の内容を加味してgroqのリクエストを作成
function createRequestMessages(promptText, isTemplate) {
    const customInstructionText = buildCustomInstructionText();
    // テンプレートの場合は、会話履歴とカスタムインストラクションを反映しない
    if (isTemplate) {
        const request = [
            {
                "role": "user",
                "content": [{ "type": "text", "text": promptText }]
            }
        ];
        if (customInstructionText) {
            request.unshift({
                "role": "system",
                "content": customInstructionText
            })
        }
        return request;
    }

    const tag = document.Tag;

    // 以前の会話が存在しない場合は新規リクエスト
    if (!tag.exists(HISTORY_TAG)) {
        const request = [{
            "role": "user",
            "content": [{ "type": "text", "text": promptText }]
        }];
        if (customInstructionText) {
            request.unshift({
                "role": "system",
                "content": customInstructionText
            })
        }
        return request;
    }

    // 会話履歴が存在する場合は、履歴を含めてリクエスト
    const request = JSON.parse(tag[HISTORY_TAG]);
    if (customInstructionText) {
        request.unshift({
            "role": "system",
            "content": customInstructionText
        });
    }
    request.push({
        "role": "user",
        "content": promptText
    });
    return request;
}

// document.Tag にgroqの応答を追加
function appendReturnTextToTag(userText, modelText) {
    const tag = document.Tag;
    if (!tag.exists(HISTORY_TAG)) {
        tag[HISTORY_TAG] = JSON.stringify([
            {
                "role": "user",
                "content": userText
            },
            {
                "role": "assistant",
                "content": modelText
            }
        ]);
    } else {
        const groqText = JSON.parse(tag[HISTORY_TAG]);
        groqText.push({
            "role": "user",
            "content": userText
        });
        groqText.push({
            "role": "assistant",
            "content": modelText
        });
        // 会話セット (user, model 1往復) が特定セット数を超えた場合、最初のセットを削除
        if (groqText.length > HISTORY_SIZE * 2) {
            groqText.splice(0, 2);
        }
        // 会話履歴全体が MAX_HISTORY_TOKEN を超えた場合、最初のセットを削除
        let joinedText = "";
        for (let i = 0; i < groqText.length; i++) {
            joinedText += groqText[i].content + "\n";
        }

        while (roughTokenize(joinedText) > MAX_HISTORY_TOKEN) {
            groqText.splice(0, 2);
            joinedText = "";
            for (let i = 0; i < groqText.length; i++) {
                joinedText += groqText[i].content + "\n";
            }
        }

        tag[HISTORY_TAG] = JSON.stringify(groqText);
    }
}

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

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"
    // + "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 configureTemporaryCustomInstruction() {
    let tempCustomInstruction = getTemporaryCustomInstruction();
    const prompt = Prompt("カスタムインストラクションを変更 ※変更すると会話履歴がリセットされます", tempCustomInstruction ? tempCustomInstruction : DEFAULT_CUSTOM_INSTRUCTION);
    if (prompt) {
        if (prompt === DEFAULT_CUSTOM_INSTRUCTION) {
            // デフォルトと同じ値の場合はタグを削除
            setTemporaryCustomInstruction("");
        } else if (prompt !== tempCustomInstruction) {
            // デフォルト以外の値が設定された場合は保存
            setTemporaryCustomInstruction(prompt);
        }
    } else if (prompt !== null) {
        // 空文字でOKされた場合はデフォルトに戻すか確認
        if (prompt === "") {
            if (Confirm("カスタムインストラクションをデフォルトに戻しますか?\n※カスタムインストラクションを空にしたい場合は [キャンセル] を選択してください。")) {
                setTemporaryCustomInstruction("");
            } else {
                setTemporaryCustomInstruction(" ");
            }
        }
    }

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

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

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

function addPreviousTemplatePromptNumberToTag(menuTrack) {
    const tag = doc.Tag;
    tag[PREVIOUS_TEMPLATE_PROMPT_TAG] = menuTrack;
}

function inputProgressChar(pos) {
    sel.SetActivePos(pos);
    sel.Text = ".";
}

変更履歴[編集]

  • 2025-12-06 #language = "quickjs" 指定を追加。各種リファクタリング
  • 2025-12-02 curl実行時のリクエスト文字列エスケープ処理を調整
  • 2025-12-01 curl実行時のリクエスト文字列エスケープ処理を調整
  • 2025-11-29 デフォルトカスタム指示を調整。Markdown出力の整形処理を調整など
  • 2025-09-12 Kimi K2 0905 モデルに対応。Markdown出力の整形処理を追加
  • 2025-04-13 初版
スポンサーリンク