0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

秀丸エディタ×ローカルLLM(Ollama)のAIコード支援マクロに「自由入力」モードを追加する

0
Last updated at Posted at 2026-07-18

はじめに

秀丸マクロからローカルLLMを呼び出す方法を試しています。この記事では実際に実行して生成されるコードの質は評価していません。

前回の記事では、秀丸エディタからローカルLLM環境の Ollama を呼び出し、選択したコードに対して次の処理を行う、Coding Agent風のAIコード支援マクロを作りました。

  • リファクタリング・バグ修正
  • 日本語コメントの追加
  • コード解説
  • テストコード生成

前回記事:秀丸エディタ×ローカルLLM(Ollama)でAIコード支援マクロを作る

前回のマクロは、あらかじめ定義した処理をメニューから選ぶ形式でした。

定型的な作業には便利なのですが、実際に使っていると次のようなこともやりたくなります。

定義済みの処理だけでなく、その場で自由に指示を入力できたら、もっと用途が広がるのでは?
何もない状態からマクロを起動して、「テトリスを作って」のような指示も出せると面白い。

そこで今回は、前回の構成へ 自由入力(フリープロンプト)モード を追加します。

追加するのは次の2つです。

  • モード5:自由入力の指示を実行し、結果を新規タブへ出力
    • 選択範囲は任意
    • 空のタブからのゼロ生成にも対応
  • モード6:自由入力の指示を実行し、選択範囲を置換
    • 既存コードへの編集指示向け

あわせて、作り込む過程で見つかった前回マクロの細かな弱点も修正します。

  • 結果取り込み時の文字化け対策
  • 置換操作のUndoグループ化
  • LLMがMarkdown付きで回答した場合の安全対策
  • プロキシ環境でのローカル接続対策

前回のコードをベースにした差分中心の記事です。OllamaやPythonのインストールなど、基本的なセットアップについては前回記事を参照してください。

この記事は前回記事の続きです。agent.pyCodingAgent.macを、前回のものから差し替える形になります。記事末尾に完成版の全文を掲載しています。

完成イメージ

使い方は次のようになります。

空の状態からコードを生成する

  1. 秀丸エディタで空の新規タブを開く

  2. マクロを起動する

  3. メニューから「5. 自由入力の指示」を選ぶ

  4. 次のような指示を入力する

    テトリスをHTML+JavaScriptで1ファイルで作って
    
  5. 新しいタブに生成結果が表示される

選択したコードを自由な指示で編集する

  1. 編集したい関数を選択する

  2. マクロを起動する

  3. メニューから「6. 自由入力の指示」を選ぶ

  4. 次のような指示を入力する

    この関数にエラー処理を追加して
    
  5. 選択範囲が生成結果で置き換わる

置換操作はUndoのグループ化を行うため、Undo 1回で元に戻せます

定義済みのメニューはそのまま残しつつ、「その場で思いついた指示」をローカルLLMへ渡せるようになります。

仕組み

全体構成は前回と同じです。

秀丸エディタ
  ↓
秀丸マクロ(CodingAgent.mac)
  ↓
Pythonスクリプト(agent.py)
  ↓
Ollama
  ↓
ローカルLLM(qwen2.5-coder:7b)

今回の主な変更は次の3つです。

  1. マクロのメニューにモード5・6を追加する
  2. モード5・6のときだけ、inputで指示文を入力してもらう
  3. 指示文をPythonへ渡し、自由入力用のプロンプトを組み立てる

さらに、既存のモードを含めて次の改善も行います。

  • 結果の取り込み時にエンコードを明示し、文字化けを防ぐ
  • 置換処理をUndo 1回で戻せるようにする
  • 不完全なMarkdown除去による誤置換を防ぐ
  • プロキシ環境変数を無視して、Ollamaへ直接接続する

今回の実装で特に注意が必要だったのが、自由入力した指示文をPythonへ渡す方法です。

ハマりどころ①:秀丸マクロにsetenvはない

秀丸マクロには環境変数を書き込むためのsetenv文はありません。

環境変数を読み取るgetenv関数はありますが、マクロから環境変数を設定する命令は用意されていません。

そこで今回は、指示文をコマンドライン引数の最後に追加する方法を採用します。

この方法には次の利点があります。

  • 指示文用の一時ファイルを増やさずに済む
  • 選択範囲に触れずに指示文を渡せる
  • 置換モードでも選択状態を維持しやすい
  • 前回のコードからの変更を小さくできる

ただし、Windowsのコマンドライン引数として文字列を渡す以上、入力できる文字列には一部制約があります。

ハマりどころ②:ダブルクォートと末尾のバックスラッシュ

指示文は次のようにダブルクォートで囲み、コマンドライン引数として渡します。

"この関数にエラー処理を追加して"

そのため、指示文そのものにダブルクォート"が含まれていると、引数を囲む引用符との区別がつかなくなります。

引用符の構造が壊れると、指示文が意図しない位置で分割されたり、後続の引数まで指示文の一部として扱われたりする可能性があります。

注意書きだけでは、誤った指示が気付かないままLLMへ渡されるかもしれません。

そこで、マクロ側でダブルクォートを検出し、含まれていた場合は処理を中止します。

if (strstr($instruction, "\"") >= 0) {
    message "指示文にダブルクォート \" は使えません。\n"
          + "シングルクォート ' で代用してください。";
    endmacro;
}

実用上は、ダブルクォートをシングルクォート'で代用すれば、ほとんどの場合は困りません。

指示文末尾のバックスラッシュにも注意

もうひとつ注意したいのが、**指示文末尾のバックスラッシュ\**です。

Windowsのコマンドライン引数では、引用符直前のバックスラッシュが特別に解釈されることがあります。

指示文の末尾が\の場合、直後にマクロ側が付けた閉じダブルクォートの解釈が変わり、引数が正しく閉じられない可能性があります。

たとえば、次のようにファイルパスで指示文を終わらせるケースです。

出力先をC:\work\

このケースもマクロ側で検出します。

if (rightstr($instruction, 1) == "\\") {
    message "指示文の末尾をバックスラッシュ \\ にすることはできません。\n"
          + "末尾に句点などを追加してください。";
    endmacro;
}

次のように、末尾へ句点などを追加すれば回避できます。

出力先をC:\work\にしてください。

長文や任意の文字列には一時ファイル方式が向いている

今回のコマンドライン引数方式は、短い1行の指示を渡すには手軽です。

一方で、次のような指示を完全に扱うには向いていません。

  • ダブルクォートを含む指示
  • 改行を維持したい長文
  • コマンドライン引数の長さを超える指示
  • 任意の文字列を制限なく渡したいケース

本格的に対応する場合は、指示文を一時ファイルへ書き出し、その一時ファイルのパスだけをPythonへ渡す方式が適しています。

今回は、実装を簡単に保つため、入力を制限したうえでコマンドライン引数を使用します。

agent.pyの変更

ここからは、Python側の主な変更点を説明します。


1. 指示文を引数から受け取る

main()の引数取り出し部分へ、指示文の受け取り処理を追加します。

in_file = sys.argv[1]
out_file = sys.argv[2]
mode = sys.argv[3]
filepath = sys.argv[4] if len(sys.argv) > 4 else ""

# 指示文。モード5・6でのみ使用する。
instruction = " ".join(sys.argv[5:]).strip() if len(sys.argv) > 5 else ""

正しく引用符で囲まれていれば、通常は指示文全体がargv[5]に入ります。

今回は、想定外の形で引数が分割された場合の簡易的な保険として、argv[5]以降をスペースで連結しています。

ただし、この処理で壊れた引用符や、失われた文字列を完全に復元できるわけではありません。そのため、マクロ側でもダブルクォートや末尾のバックスラッシュを検査します。

なお、無題のタブでマクロを実行した場合、秀丸側のfilename2は空文字になります。

その場合も引数として""が渡されるため、argv[4]は空文字になります。拡張子から言語を判定できないため「不明な言語」扱いになりますが、モード5のゼロ生成には問題ありません。

2. モード5では選択範囲がなくても動くようにする

モード5では、選択範囲がない状態からコードを生成できるようにします。

入力用の一時ファイルが存在しない場合でも、モード5だけはエラーにしません。

code = ""

if os.path.exists(in_file):
    try:
        with open(in_file, "r", encoding="utf-8-sig") as f:
            code = f.read()
    except Exception as e:
        fail(out_file, f"入力ファイルの読み込みに失敗しました: {e}")
elif mode != "5":
    fail(
        out_file,
        "入力ファイルが見つかりません。"
        "コードを選択してから実行してください。"
    )

# モード1〜4とモード6は選択範囲が必須
if mode != "5" and not code.strip():
    fail(
        out_file,
        "選択範囲が空です。"
        "コードを選択してから実行してください。"
    )

整理すると、選択範囲の扱いは次のようになります。

モード 選択範囲
1 必須
2 必須
3 必須
4 必須
5 任意
6 必須

3. モード5・6のプロンプトを追加する

既存のモード1〜4のプロンプトに加えて、モード5・6用のプロンプトを組み立てます。

モード5:新規タブへ出力

モード5では、選択範囲の有無でプロンプトを分けます。

選択範囲がある場合は、そのコードを指示の対象として渡します。

if mode == "5":
    if not instruction:
        fail(
            out_file,
            "指示が空です。"
            "マクロ側で指示を入力してから実行してください。"
        )

    if code.strip():
        prompt_text = (
            "あなたは優秀なプログラマーです。"
            "次の指示に従ってください。\n"
            "コードを出力する場合は、"
            "そのまま使える完全なコードにしてください。\n\n"
            f"# 指示\n{instruction}\n\n"
            f"# 対象コード({language}\n{code}\n"
        )

選択範囲がない場合は、ゼロからコードを生成するプロンプトにします。

    else:
        prompt_text = (
            "あなたは優秀なプログラマーです。"
            "次の指示に従ってコードを作成してください。\n"
            "省略せず、そのまま実行できる"
            "完全なコードを出力してください。\n"
            "最後に、簡単な使い方を日本語で添えてください。\n\n"
            f"# 指示\n{instruction}\n"
        )

モード5は新規タブへ出力するため、説明文やMarkdownが含まれていても、そのまま表示します。

モード6:選択範囲を置換

モード6は、選択範囲を生成結果で直接置き換えます。

エディタへ挿入できるよう、コード本文だけを返すように指示します。

elif mode == "6":
    if not instruction:
        fail(
            out_file,
            "指示が空です。"
            "マクロ側で指示を入力してから実行してください。"
        )

    prompt_text = (
        f"以下の{language}コードに対して、"
        "次の指示を実行してください。\n\n"
        f"# 指示\n{instruction}\n\n"
        "解説、前置き、Markdown、"
        "コードフェンスは出力しないでください。\n"
        "エディタにそのまま挿入できる、"
        "コード本文だけを出力してください。\n\n"
        f"# 対象コード\n{code}\n"
    )

4. Markdown除去をモード6へ対応させ、安全側に倒す

置換モードでは、LLMの回答をそのまま編集中のコードへ挿入します。

プロンプトで「コード本文だけを出力してください」と指定しても、軽量なモデルでは次のような回答が返ることがあります。

修正後のコードです。

```python
def example():
    pass
```

前回は、コードフェンスが存在した場合にコードブロックを抽出していました。

しかし、複数のコードフェンスが返された場合に「最初のブロック」や「最長のブロック」を採用すると、必要なコードの一部を失う可能性があります。

たとえば、次のような回答です。

前半です。

```python
def function_a():
    pass
```

後半です。

```python
def function_b():
    pass
```

この場合、どちらか一方だけを採用して置換すると、コードが不完全になります。

そこで、置換モードでは安全側に倒し、次のルールにします。

  • 回答全体が1つのコードフェンスで囲まれている場合だけフェンスを外す
  • 複数のコードフェンスがある場合は置換を中止する
  • コードフェンスの外に説明文がある場合も置換を中止する
  • コードフェンスがない場合は、そのままコード本文として扱う

抽出関数は次のようにします。

def extract_code_from_markdown(text):
    stripped = text.strip()

    blocks = re.findall(
        r"```[^\r\n]*\r?\n(.*?)(?:\r?\n)?```",
        stripped,
        re.DOTALL,
    )

    if len(blocks) > 1:
        raise ValueError(
            "AIの回答に複数のコードブロックが含まれています。"
        )

    if len(blocks) == 1:
        # 回答全体が1つのコードフェンスで囲まれている場合だけ、
        # フェンスを取り除く。
        match = re.fullmatch(
            r"```[^\r\n]*\r?\n(.*?)(?:\r?\n)?```",
            stripped,
            re.DOTALL,
        )

        if not match:
            raise ValueError(
                "AIの回答にコードフェンス外の説明文が含まれています。"
            )

        return match.group(1)

    return stripped

置換モード1・2・6でこの関数を使います。

if mode in ("1", "2", "6"):
    try:
        result_text = extract_code_from_markdown(result_text)
    except ValueError as e:
        fail(
            out_file,
            f"置換用コードを安全に抽出できませんでした: {e}"
        )

コードフェンスがない説明文をLLMが返した場合、それがコードかどうかを機械的に完全判定することはできません。

そのため、この対策ですべての誤出力を防げるわけではありません。置換モードを実行した後は、必ず結果を確認してください。

5. プロキシ環境でもローカル接続する

urllibは、環境変数のHTTP_PROXYHTTPS_PROXYなどを自動的に利用する場合があります。

社用PCなど、プロキシが設定されている環境では、localhost:11434への接続までプロキシへ送られ、Ollamaへの接続に失敗することがあります。

Ollamaはローカルで動作しているため、今回はプロキシ環境変数を無視し、直接接続します。

opener = urllib.request.build_opener(
    urllib.request.ProxyHandler({})
)

try:
    with opener.open(req, timeout=TIMEOUT_SEC) as res:
        raw = res.read().decode("utf-8")
        response = json.loads(raw)

CodingAgent.macの変更

続いて、秀丸マクロ側の変更です。

1. メニューへモード5・6を追加する

メニューへ自由入力モードを追加します。

menu "1. リファクタリング・バグ修正 (選択範囲を置換)",
     "2. コメントを追加             (選択範囲を置換)",
     "3. コードを詳しく解説         (新規タブ)",
     "4. テストコードを生成         (新規タブ)",
     "5. 自由入力の指示             (新規タブ / 選択は任意)",
     "6. 自由入力の指示             (選択範囲を置換)";

2. 選択チェックをモードごとに行う

前回は、マクロの冒頭で無条件に選択範囲を要求していました。

今回はモード5だけ選択なしで実行できるようにするため、メニュー選択後にチェックします。

#mode = result;

// モード5だけは選択なしでも実行できる。
// モード1〜4とモード6は選択範囲が必須。
if (#mode != 5) {
    if (!selecting) {
        message "対象のコードを選択してから実行してください。";
        endmacro;
    }
}

3. モード5・6では指示文を入力する

自由入力モードでは、input関数を使って指示文を入力してもらいます。

input関数は、OKが押された場合にresultが真、キャンセルされた場合に偽になります。

$instruction = "";

if (#mode == 5 || #mode == 6) {
    $instruction = input(
        "AIへの指示を入力してください(例:テトリスをHTML+JavaScriptで1ファイルで作って):"
    );

    if (!result || $instruction == "") {
        endmacro;
    }

続けて、コマンドライン引数として安全に渡せない入力を検査します。

    // ダブルクォートが含まれていると、
    // 引数を囲む引用符が壊れる。
    if (strstr($instruction, "\"") >= 0) {
        message "指示文にダブルクォート \" は使えません。\n"
              + "シングルクォート ' で代用してください。";
        endmacro;
    }

    // 末尾のバックスラッシュは、
    // 閉じ引用符の解釈を壊す可能性がある。
    if (rightstr($instruction, 1) == "\\") {
        message "指示文の末尾をバックスラッシュ \\ にすることはできません。\n"
              + "末尾に句点などを追加してください。";
        endmacro;
    }
}

4. 選択範囲がある場合だけ一時ファイルへ保存する

モード5は、選択範囲がない状態でも実行できます。

そのため、selectingが真の場合だけ入力用一時ファイルを作ります。

if (selecting) {
    saveas $temp_in, utf8, selection;

    if (!result) {
        message "選択範囲の保存に失敗しました。\n"
              + "BOX選択や複数選択ではなく、"
              + "通常の範囲選択で実行してください。";
        endmacro;
    }
}

選択範囲がない場合は、入力用一時ファイルを作りません。

agent.py側では、モード5に限り、入力ファイルが存在しない場合を空コードとして扱います。

5. コマンドラインの最後へ指示文を追加する

Pythonを呼び出すコマンドラインの最後へ、自由入力した指示文を追加します。

$cmd = "\"" + $python_exe + "\" "
     + "\"" + $python_script + "\" "
     + "\"" + $temp_in + "\" "
     + "\"" + $temp_out + "\" "
     + str(#mode) + " "
     + "\"" + filename2 + "\" "
     + "\"" + $instruction + "\"";

runsync2 $cmd;

モード1〜4では$instructionは空文字なので、最後の引数には""が渡されます。

runsync2は同期実行です。Ollamaから回答が返ってくるまで、秀丸エディタを操作できません。

実行中であることが分かるように、タイトルバーへメッセージを表示します。

title "AIが処理中です… しばらくお待ちください";

title文の第2パラメータへ1を指定すると、表示先はタイトルバーではなくステータスバーになります。

今回はタイトルバーへ表示するため、第2パラメータは指定しません。

この表示は、マクロ終了時に自動的に元へ戻ります。

6. モード6を置換対象へ追加する

実行結果の反映先を、次のように分けます。

  • モード1・2・6:選択範囲を置換
  • モード3・4・5:新規タブへ表示
if (#mode == 1 || #mode == 2 || #mode == 6) {
    // 選択範囲を置換
} else {
    // 新規タブへ表示
}

7. 置換をUndo 1回で元に戻せるようにする

前回の実装では、deleteinsertfileが別々のUndo単位になることがありました。

その場合、置換前の状態へ戻すためにUndoが2回必要になります。

begingroupundoendgroupundoで置換処理を囲むことで、削除と挿入を1つのUndo単位にまとめます。

begingroupundo;
delete;
insertfile $temp_out, utf8;
endgroupundo;

これにより、置換操作をUndo 1回で元に戻せます。


8. insertfileのエンコードを明示する

agent.pyは、結果をBOMなしUTF-8で書き出します。

insertfileのエンコードを自動判定に任せると、短い日本語テキストなどがShift-JISとして誤判定され、文字化けする場合があります。

そこで、結果ファイルを挿入するときはUTF-8を明示します。

insertfile $temp_out, utf8;

置換モード、新規タブ表示モード、エラー表示のすべてでUTF-8を指定します。

使い方

コードを選択してマクロを実行すると、次のメニューが表示されます。

モード5でゼロから生成する場合は、選択範囲は必要ありません。

No. 機能 出力先 選択範囲
1 リファクタリング・バグ修正 選択範囲を置換 必須
2 コメントを追加 選択範囲を置換 必須
3 コードを詳しく解説 新規タブ 必須
4 テストコードを生成 新規タブ 必須
5 自由入力の指示 新規タブ 任意
6 自由入力の指示 選択範囲を置換 必須

モード5とモード6の使い分け

モード5:新規タブへ出力

モード5の結果は、必ず新しいタブに表示されます。

元のコードを変更しないため、内容を確認してから利用できます。

主な用途は次のとおりです。

  • 空のタブから「○○を作って」と指示し、コードをゼロから生成する
  • 選択したコードを別の言語へ変換する
  • コードを参照させて質問する
  • 改善版を新規タブへ生成し、元コードと比較する
  • 複数の変更案を出してもらう

たとえば、空のタブで次のように指示できます。

テトリスをHTML+JavaScriptで1ファイルで作って

コードを選択した状態では、次のような指示も可能です。

このコードをTypeScriptに書き換えて
この処理の問題点を説明して、改善版も出して

モード6:選択範囲を置換

モード6は、選択したコードをその場で編集するときに使います。

たとえば、次のような指示です。

この関数にエラー処理を追加して
変数名を分かりやすくして
重複処理を関数にまとめて
処理結果は変えずに、読みやすく整理して

モード6では、生成結果が選択範囲へ直接挿入されます。

Undo 1回で元に戻せますが、生成AIの回答が必ず正しいとは限りません。実行後は必ず内容を確認してください。

発展:長い指示は複数行の入力欄で

秀丸エディタV9.10以降では、input関数の第3パラメータへ0x0100を指定すると、複数行の入力ボックスを表示できます。

V9.12以降では、さらに0x0200を加えることで折り返しも有効にできます。

$instruction = input(
    "AIへの指示を入力してください:",
    "",
    0x0300
);

ただし、複数行入力の結果には改行が含まれます。

そのままコマンドライン引数として渡すのは避け、渡す前に改行をスペースへ置換します。

$instruction = strreplace($instruction, "\r\n", " ");
$instruction = strreplace($instruction, "\n", " ");

strreplaceはV9.16以降の関数です。

そのため、次のサンプルコード全体をそのまま使用するには、秀丸エディタV9.16以降が必要です。

// V9.16以降向け
// 複数行入力(0x0100)+ 折り返し(0x0200)
$instruction = input(
    "AIへの指示を入力してください:",
    "",
    0x0300
);

if (!result || $instruction == "") {
    endmacro;
}

// 改行をスペースへ変換する
$instruction = strreplace($instruction, "\r\n", " ");
$instruction = strreplace($instruction, "\n", " ");

// コマンドライン引数として安全に渡せない入力を検査する
if (strstr($instruction, "\"") >= 0) {
    message "指示文にダブルクォート \" は使えません。\n"
          + "シングルクォート ' で代用してください。";
    endmacro;
}

if (rightstr($instruction, 1) == "\\") {
    message "指示文の末尾をバックスラッシュ \\ にすることはできません。\n"
          + "末尾に句点などを追加してください。";
    endmacro;
}

古いバージョンも対象にするため、記事末尾の完成版では従来の1行入力を使っています。

なお、この方法では改行をスペースへ変換するため、入力時の段落構造は失われます。

改行を維持したまま長い指示を渡したい場合は、コマンドライン引数ではなく、指示文を一時ファイルで受け渡す方式へ変更してください。

注意点

指示文の制約

今回の実装では、指示文をコマンドライン引数として渡します。

そのため、次の制約があります。

  • 指示文にダブルクォート"は使用できません
  • 指示文の末尾をバックスラッシュ\にできません
  • 長すぎる指示には向いていません
  • 改行を維持したまま渡す用途には向いていません

ダブルクォートが必要な場合は、シングルクォート'で代用してください。

ファイルパスなどのバックスラッシュで指示文が終わる場合は、末尾に句点などを追加してください。

任意の文字列や長文を扱いたい場合は、一時ファイル方式を検討してください。

文字コード

結果ファイルは、insertfile ..., utf8によってUTF-8として読み込みます。

ただし、編集中のファイル自体がShift-JISの場合、生成結果にShift-JISで表現できない文字が含まれていると、保存時に文字コード変換の警告や、文字の欠落・置換が発生する可能性があります。

可能であれば、UTF-8のファイルで利用してください。

選択範囲

選択範囲の保存には、次の命令を使用します。

saveas $temp_in, utf8, selection;

通常の範囲選択で実行してください。

BOX選択や複数選択には対応していません。

置換結果の確認

生成AIの出力には誤りが含まれる可能性があります。

特に、選択範囲を直接変更するモード1・2・6を使用した後は、必ず内容を確認してください。

置換処理はUndo 1回で元に戻せます。

Markdown付き回答

置換モードで、AIが複数のコードブロックや、説明文付きのコードフェンスを返した場合は、安全のため置換を中止します。

エラー内容は新しいタブに表示されます。

一方、コードフェンスのない説明文については、コードかどうかを完全には判定できません。置換後の確認は必ず行ってください。

実行中は秀丸を操作できない

runsync2は同期実行です。

Ollamaから回答が返るまで、秀丸エディタを操作できません。

実行中はタイトルバーに次のメッセージが表示されます。

AIが処理中です… しばらくお待ちください

長時間停止して見える場合は、この表示を確認してください。

プロキシ環境

agent.pyではプロキシ環境変数を使用せず、localhostのOllamaへ直接接続します。

社用PCなどでHTTP_PROXYHTTPS_PROXYが設定されていても、ローカル接続がプロキシへ送られないようにしています。

モデルの性能

qwen2.5-coder:7bは比較的軽量なモデルです。

「テトリスを作って」のような長いコード生成では、次のようなことがあります。

  • 生成結果が不完全になる
  • コードが途中で終わる
  • 実行に時間がかかる
  • 指示した形式を守らない
  • タイムアウトする

タイムアウトする場合は、agent.pyTIMEOUT_SECを増やしてください。

精度を重視する場合は、実行環境に余裕があれば、より大きなモデルへの変更も検討してください。

まとめ

前回のマクロに、その場で指示を出せる 自由入力モード を追加しました。

今回追加したモードは次の2つです。

  • モード5:自由入力の結果を新規タブへ表示
  • モード6:自由入力の結果で選択範囲を置換

これにより、次のような使い分けができます。

  • 定義済みモード:繰り返し行う定型作業
  • 自由入力モード:その場限りの生成や編集

また、自由入力モードの追加にあわせて、既存の処理も改善しました。

  • 選択なしでのゼロ生成に対応
  • 結果ファイルをUTF-8として読み込む
  • 置換操作をUndo 1回で戻せるようにする
  • 複数のコードブロックが返された場合は置換を中止する
  • プロキシ環境変数を無視してOllamaへ直接接続する
  • ダブルクォートと末尾のバックスラッシュを入力時に検査する

秀丸マクロには環境変数を書き込む命令がないため、今回は指示文をコマンドライン引数として渡しました。

この方法は短い1行入力には手軽ですが、Windowsのコマンドライン引数の解釈に起因する制約があります。そのため、マクロ側で入力内容を検査しています。

まずは、UTF-8の小さなコードと軽い指示から試してみてください。

置換モードを使用した後は、必ず生成結果を確認してください。長文や複数行の指示を本格的に扱いたくなった場合は、指示文を一時ファイルで渡す方式への拡張がおすすめです。

完成版の全文

agent.py(全文)
# -*- coding: utf-8 -*-
# agent.py : 秀丸マクロ CodingAgent.mac から呼ばれる中継スクリプト
#
# 引数1: 入力ファイル
#         選択範囲のコード、UTF-8
#         自由入力モード5では存在しないことがある
# 引数2: 出力ファイル
#         AIの回答を書き出す、UTF-8
# 引数3: モード番号
#         "1"~"6"
# 引数4: 元ファイルのフルパス
#         拡張子から言語を判定する
#         無題のときは空文字
# 引数5以降:
#         自由入力モード5・6の指示文

import sys
import os
import re
import json
import urllib.request
import urllib.error


MODEL = "qwen2.5-coder:7b"
OLLAMA_URL = "http://localhost:11434/api/generate"
TIMEOUT_SEC = 600


def fail(out_file, msg):
    """エラー内容を出力ファイルと標準エラーへ書き込み、終了する。"""
    try:
        with open(out_file, "w", encoding="utf-8") as f:
            f.write(msg)
    except Exception:
        pass

    sys.stderr.write(msg + "\n")
    sys.exit(1)


def detect_language_from_path(filepath):
    """ファイルの拡張子から、プロンプトに使用する言語名を返す。"""
    ext = os.path.splitext(filepath)[1].lower()

    if not ext:
        return "不明な言語"

    mapping = {
        ".py": "Python",
        ".js": "JavaScript",
        ".ts": "TypeScript",
        ".jsx": "React JSX",
        ".tsx": "React TSX",
        ".html": "HTML",
        ".css": "CSS",
        ".scss": "SCSS",
        ".java": "Java",
        ".cs": "C#",
        ".cpp": "C++",
        ".c": "C",
        ".h": "C/C++ header",
        ".hpp": "C++ header",
        ".go": "Go",
        ".rs": "Rust",
        ".php": "PHP",
        ".rb": "Ruby",
        ".swift": "Swift",
        ".kt": "Kotlin",
        ".sql": "SQL",
        ".sh": "Shell script",
        ".bat": "Windows batch",
        ".ps1": "PowerShell",
        ".json": "JSON",
        ".xml": "XML",
        ".yaml": "YAML",
        ".yml": "YAML",
        ".md": "Markdown",
    }

    return mapping.get(ext, ext)


def extract_code_from_markdown(text):
    """
    置換用の回答から、安全にコードフェンスを取り除く。

    ・回答全体が1つのコードフェンスの場合だけフェンスを外す
    ・複数のコードフェンスがある場合はエラー
    ・フェンス外に説明文がある場合もエラー
    ・コードフェンスがない場合は、そのまま返す
    """
    stripped = text.strip()

    blocks = re.findall(
        r"```[^\r\n]*\r?\n(.*?)(?:\r?\n)?```",
        stripped,
        re.DOTALL,
    )

    if len(blocks) > 1:
        raise ValueError(
            "AIの回答に複数のコードブロックが含まれています。"
        )

    if len(blocks) == 1:
        match = re.fullmatch(
            r"```[^\r\n]*\r?\n(.*?)(?:\r?\n)?```",
            stripped,
            re.DOTALL,
        )

        if not match:
            raise ValueError(
                "AIの回答にコードフェンス外の説明文が含まれています。"
            )

        return match.group(1)

    return stripped


def main():
    if len(sys.argv) < 4:
        sys.stderr.write("引数が不足しています。\n")
        sys.exit(1)

    in_file = sys.argv[1]
    out_file = sys.argv[2]
    mode = sys.argv[3]
    filepath = sys.argv[4] if len(sys.argv) > 4 else ""

    # モード5・6で使用する自由入力の指示文。
    # 通常はargv[5]に入るが、簡易的な保険として
    # argv[5]以降をスペースで連結する。
    instruction = (
        " ".join(sys.argv[5:]).strip()
        if len(sys.argv) > 5
        else ""
    )

    language = detect_language_from_path(filepath)

    # 選択範囲のコードを読み込む。
    # モード5だけは入力ファイルが存在しなくてもよい。
    code = ""

    if os.path.exists(in_file):
        try:
            with open(in_file, "r", encoding="utf-8-sig") as f:
                code = f.read()
        except Exception as e:
            fail(
                out_file,
                f"入力ファイルの読み込みに失敗しました: {e}",
            )

    elif mode != "5":
        fail(
            out_file,
            "入力ファイルが見つかりません。"
            "コードを選択してから実行してください。",
        )

    # モード1~4とモード6は選択範囲が必須。
    if mode != "5" and not code.strip():
        fail(
            out_file,
            "選択範囲が空です。"
            "コードを選択してから実行してください。",
        )

    prompts = {
        "1": (
            f"以下の{language}コードをリファクタリングし、"
            "明らかなバグがあれば修正してください。\n"
            "ただし、仕様を勝手に変更しないでください。\n"
            "解説、前置き、Markdown、"
            "コードフェンスは出力しないでください。\n"
            "修正後のコード本文だけを出力してください。\n\n"
        ),
        "2": (
            f"以下の{language}コードに、"
            "分かりやすい日本語コメントを追加してください。\n"
            "コードの動作は変更しないでください。\n"
            "解説、前置き、Markdown、"
            "コードフェンスは出力しないでください。\n"
            "コメント追加後のコード本文だけを出力してください。\n\n"
        ),
        "3": (
            f"以下の{language}コードが何をしているか、"
            "日本語で詳しく解説してください。\n"
            "処理の流れ、主要な変数・関数の役割、"
            "注意点、改善案があれば説明してください。\n\n"
        ),
        "4": (
            f"以下の{language}コードに対する"
            "テストコードを作成してください。\n"
            "可能であれば、想定するテストフレームワーク、"
            "テスト観点、境界条件も説明してください。\n\n"
        ),
    }

    if mode == "5":
        # 自由入力モード。
        # 結果は新規タブへ表示する。
        if not instruction:
            fail(
                out_file,
                "指示が空です。"
                "マクロ側で指示を入力してから実行してください。",
            )

        if code.strip():
            # 選択範囲がある場合は、
            # 対象コードとして指示と一緒に渡す。
            prompt_text = (
                "あなたは優秀なプログラマーです。"
                "次の指示に従ってください。\n"
                "コードを出力する場合は、"
                "そのまま使える完全なコードにしてください。\n\n"
                f"# 指示\n{instruction}\n\n"
                f"# 対象コード({language}\n{code}\n"
            )

        else:
            # 選択範囲がない場合は、ゼロから生成する。
            prompt_text = (
                "あなたは優秀なプログラマーです。"
                "次の指示に従ってコードを作成してください。\n"
                "省略せず、そのまま実行できる"
                "完全なコードを出力してください。\n"
                "最後に、簡単な使い方を日本語で添えてください。\n\n"
                f"# 指示\n{instruction}\n"
            )

    elif mode == "6":
        # 自由入力による置換モード。
        # エディタへ直接挿入するため、
        # コード本文だけを出力するよう指示する。
        if not instruction:
            fail(
                out_file,
                "指示が空です。"
                "マクロ側で指示を入力してから実行してください。",
            )

        prompt_text = (
            f"以下の{language}コードに対して、"
            "次の指示を実行してください。\n\n"
            f"# 指示\n{instruction}\n\n"
            "解説、前置き、Markdown、"
            "コードフェンスは出力しないでください。\n"
            "エディタにそのまま挿入できる、"
            "コード本文だけを出力してください。\n\n"
            f"# 対象コード\n{code}\n"
        )

    else:
        prompt_text = (
            prompts.get(
                mode,
                "以下のコードを処理してください:\n\n",
            )
            + code
        )

    payload = {
        "model": MODEL,
        "prompt": prompt_text,
        "stream": False,
        "keep_alive": "30m",
        "options": {
            "temperature": 0.1,
        },
    }

    req = urllib.request.Request(
        OLLAMA_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
        },
    )

    # HTTP_PROXYやHTTPS_PROXYなどの環境変数を使用せず、
    # localhostのOllamaへ直接接続する。
    opener = urllib.request.build_opener(
        urllib.request.ProxyHandler({})
    )

    try:
        with opener.open(req, timeout=TIMEOUT_SEC) as res:
            raw = res.read().decode("utf-8")
            response = json.loads(raw)

        if "error" in response:
            fail(
                out_file,
                f"Ollama API エラー: {response['error']}",
            )

        result_text = response.get("response", "")

    except urllib.error.URLError as e:
        fail(
            out_file,
            "AI通信エラーが発生しました。\n"
            f"詳細: {e}\n\n"
            "確認してください:\n"
            "・Ollamaが起動しているか\n"
            f"・Ollama APIが{OLLAMA_URL}で待ち受けているか\n"
            f"・モデル{MODEL}がインストール済みか\n",
        )

    except json.JSONDecodeError as e:
        fail(
            out_file,
            "Ollamaからの応答をJSONとして解析できませんでした。\n"
            f"詳細: {e}\n",
        )

    except Exception as e:
        fail(
            out_file,
            "AI処理中に予期しないエラーが発生しました。\n"
            f"詳細: {e}\n",
        )

    # 置換モードではコードフェンスを安全に除去する。
    if mode in ("1", "2", "6"):
        try:
            result_text = extract_code_from_markdown(result_text)
        except ValueError as e:
            fail(
                out_file,
                f"置換用コードを安全に抽出できませんでした: {e}",
            )

    result_text = result_text.strip("\n")

    if not result_text.strip():
        fail(
            out_file,
            "AIから有効な回答が得られませんでした。",
        )

    try:
        with open(out_file, "w", encoding="utf-8") as f:
            f.write(result_text)

    except Exception as e:
        fail(
            out_file,
            f"出力ファイルの書き込みに失敗しました: {e}",
        )


if __name__ == "__main__":
    main()
CodingAgent.mac(全文)
// ============================================================
//  CodingAgent.mac(自由入力対応版)
//
//  秀丸エディタ × ローカルLLM(Ollama)
//  AIコード支援マクロ
//
//  対応:
//    秀丸エディタ Ver 8.58以降
//    saveas ... selectionを使用
//
//  機能:
//    1. リファクタリング・バグ修正
//       選択範囲を置換
//
//    2. コメント追加
//       選択範囲を置換
//
//    3. コード解説
//       新規タブに表示
//
//    4. テストコード生成
//       新規タブに表示
//
//    5. 自由入力の指示
//       新規タブに表示
//       選択範囲は任意
//
//    6. 自由入力の指示
//       選択範囲を置換
//       選択範囲は必須
// ============================================================


// ------------------------------------------------------------
// メニュー表示
// ------------------------------------------------------------
menu "1. リファクタリング・バグ修正 (選択範囲を置換)",
     "2. コメントを追加             (選択範囲を置換)",
     "3. コードを詳しく解説         (新規タブ)",
     "4. テストコードを生成         (新規タブ)",
     "5. 自由入力の指示             (新規タブ / 選択は任意)",
     "6. 自由入力の指示             (選択範囲を置換)";

if (result == 0) {
    endmacro;
}

#mode = result;


// ------------------------------------------------------------
// モードごとの前提チェック
//
// モード5だけは選択範囲なしでも実行できる。
// モード1~4とモード6は選択範囲が必須。
// ------------------------------------------------------------
if (#mode != 5) {
    if (!selecting) {
        message "対象のコードを選択してから実行してください。";
        endmacro;
    }
}


// ------------------------------------------------------------
// 自由入力モード5・6の指示文を入力
//
// inputはOKでresultがtrue、キャンセルでfalseになる。
// コマンドライン引数として安全に渡せない入力も検査する。
// ------------------------------------------------------------
$instruction = "";

if (#mode == 5 || #mode == 6) {
    $instruction = input(
        "AIへの指示を入力してください(例:テトリスをHTML+JavaScriptで1ファイルで作って):"
    );

    if (result == 0 || $instruction == "") {
        endmacro;
    }

    // ダブルクォートが含まれていると、
    // 引数を囲む引用符が壊れる。
    if (strstr($instruction, "\"") >= 0) {
        message "指示文にダブルクォート \" は使えません。\n"
              + "シングルクォート ' で代用してください。";
        endmacro;
    }

    // 末尾のバックスラッシュは、
    // 閉じ引用符の解釈を壊す可能性がある。
    if (rightstr($instruction, 1) == "\\") {
        message "指示文の末尾をバックスラッシュ \\ にすることはできません。\n"
              + "末尾に句点などを追加してください。";
        endmacro;
    }
}


// ------------------------------------------------------------
// パス設定
// ------------------------------------------------------------

// Python実行コマンド。
// うまく動かない場合はpython.exeのフルパスを指定する。
$python_exe = "python";

// agent.pyを保存した場所に合わせて変更する。
$python_script = "C:\\hidemaru_ai\\agent.py";

// 一時ファイル名。
// tickcountを付けて、実行ごとに名前を変える。
$temp_base = getenv("TEMP")
           + "\\hm_llm_"
           + str(tickcount);

$temp_in  = $temp_base + "_in.txt";
$temp_out = $temp_base + "_out.txt";


// ------------------------------------------------------------
// 選択範囲をUTF-8の一時ファイルへ保存
//
// モード5では選択範囲がない場合があるため、
// selectingがtrueの場合だけ保存する。
// ------------------------------------------------------------
if (selecting) {
    saveas $temp_in, utf8, selection;

    if (!result) {
        message "選択範囲の保存に失敗しました。\n"
              + "BOX選択や複数選択ではなく、"
              + "通常の範囲選択で実行してください。";
        endmacro;
    }
}

// 選択範囲がない場合は$temp_inを作らない。
// agent.py側では、モード5に限り空コードとして扱う。


// ------------------------------------------------------------
// Python経由でOllamaを同期実行
//
// runsync2は同期実行。
// 回答が返るまで秀丸エディタを操作できない。
// ------------------------------------------------------------

// タイトルバーへ実行中メッセージを表示する。
// マクロ終了時に自動的に元へ戻る。
title "AIが処理中です… しばらくお待ちください";


// 最後の引数として自由入力の指示文を渡す。
// モード1~4では空文字が渡される。
$cmd = "\"" + $python_exe + "\" "
     + "\"" + $python_script + "\" "
     + "\"" + $temp_in + "\" "
     + "\"" + $temp_out + "\" "
     + str(#mode) + " "
     + "\"" + filename2 + "\" "
     + "\"" + $instruction + "\"";

runsync2 $cmd;


// ------------------------------------------------------------
// 実行結果の検査
//
// result:
//   外部プログラムの起動結果
//
// getresultex(9):
//   外部プログラムの終了コード
// ------------------------------------------------------------
if (result == 0 || getresultex(9) != 0) {
    message "AI処理に失敗しました。\n"
          + "詳細が出力されている場合は、"
          + "別タブで開きます。\n\n"
          + "確認してください:\n"
          + "・Ollamaが起動しているか\n"
          + "・モデルがインストール済みか\n"
          + "・Pythonが実行できるか\n"
          + "・PythonのPATHまたはフルパスが正しいか\n"
          + "・agent.pyのパスが正しいか";

    // agent.pyがエラー内容を書き出していた場合は、
    // 新規タブへ表示する。
    if (existfile($temp_out)) {
        newfile;
        insertfile $temp_out, utf8;
    }

    // 一時ファイルを削除する。
    if (existfile($temp_in)) {
        deletefile $temp_in;
    }

    if (existfile($temp_out)) {
        deletefile $temp_out;
    }

    endmacro;
}


// 入力用一時ファイルは不要になったため削除する。
if (existfile($temp_in)) {
    deletefile $temp_in;
}


// ------------------------------------------------------------
// 実行結果の反映
//
// モード1・2・6:
//   選択範囲を置換
//
// モード3・4・5:
//   新規タブへ表示
//
// begingroupundo / endgroupundo:
//   削除と挿入を1回のUndo単位にまとめる
//
// insertfile ..., utf8:
//   結果ファイルをUTF-8として読み込む
// ------------------------------------------------------------
if (#mode == 1 || #mode == 2 || #mode == 6) {
    disabledraw;

    begingroupundo;
    delete;
    insertfile $temp_out, utf8;
    endgroupundo;

    enabledraw;

} else {
    newfile;
    insertfile $temp_out, utf8;
}


// 出力用一時ファイルを削除する。
if (existfile($temp_out)) {
    deletefile $temp_out;
}

endmacro;
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?