Denops x LangChainでコミットメッセージを自動生成するplugin作った話
はじめに
vimconfにて、AlisueさんのDenopsに関する講演を聞いて、Denopsの存在自体は知っていたものの、聞いたことがある、程度の理解だったため
とても興味が湧き自分でDenopsでのプラグインを開発したくなりました。
初のDenopsを用いての開発なのでベストプラクティスに則っていない部分があるかもしれないですが、
是非コメント等で指摘をいただけるとすごく嬉しいです!
今回は、作ったプラグインの紹介とソースコードの解説をしようと思います。
今回作ったプラグインの紹介
gitのdiffからcommit messageの生成と、git add/commitまでをやってくれるプラグインを開発しました。
main.goの一部を修正し、(Hello, Docker!
-> Hello, World!
) コミットメッセージの生成から自動コミットまで
モチベーションとしては、GitHub Copilotでのcommit message生成の体験がすごく良かったので、vimにも欲しいなと思った次第です。
(早く実装されて欲しいです...(;p;))
使用技術の紹介
Denops
公式の説明を日本語に翻訳したものを以下に貼り付けています
Denops は、開発者がDenoでプラグインを作成できるようにする Vim/Neovim のエコシステムです。次のような特徴があります。
- 同じコードを Vim と Neovim の両方で使用できます
- Vim プラグインとしてインストール可能
- Deno は Vim スクリプトよりもはるかに高速な V8 エンジンを使用しています
- ユーザーはライブラリの依存関係を管理する必要がありません
- Denops は別のプロセスとして実行されるため、Vim がフリーズすることはありません
- 各プラグインは独自のスレッドで動作するため、干渉の可能性が低くなります。
vimとneovimでAPIが異なっていたりすることから、vim pluginの開発の難易度は上がっています。
そこでDenopsを用いることで、vim/neovimのAPIの抽象化をしてくれたり、
他にはjs/tsをサポートしているため豊富なライブラリを用いてのプラグイン開発を行えるので
開発者体験がすごく良いです。
LangChain
もちろんこれを使わずにChatGPTのAPIなどを叩いて開発をすることもできますが、
LangChainを用いることで、 各LLMごとの抽象化だったり、プロンプトテンプレートなどの便利な機能を支えたり
効率良く開発をすることができます。
Denops x LangChain
DenopsとLangChainと組み合わせた最小サンプルは以下となります。
import { Denops } from "https://deno.land/x/denops_std@v2.0.0/mod.ts";
import { ensureString } from "https://deno.land/x/unknownutil@v1.0.0/mod.ts";
import { OpenAIChat } from "npm:langchain/llms/openai";
import { PromptTemplate } from "npm:langchain/prompts";
export async function main(denops: Denops): Promise<void> {
const llm = new OpenAIChat({
openAIApiKey: "TODO",
});
const promptTemplate = new PromptTemplate({
inputVariables: ["input"],
template: `
{input}
`,
});
denops.dispatcher = {
async sendChatMessage(text: unknown) {
ensureString(text);
const prompt = await promptTemplate.format({ input: text });
const result = await llm.call(prompt);
console.log(result);
return Promise.resolve(result);
},
};
const n = denops.name;
await denops.cmd(
`command! SendChatMessage call denops#notify("${n}", "sendChatMessage", [input("Enter your message: ")])`
);
}
たったこれだけのソースコードでOpenAIとvimの繋ぎ込みが完了し、vim上からchatを行うことができます。
Denopsがjs/tsをサポートしているため、豊富なライブラリ(今回だとLangChain)を扱えることができ、
簡単にLLMを用いたプラグインのベースを完成させることができました。
今回開発したai-commits.vim
今回作成したプラグインのソースコードの詳細を解説します。
ソースコード全文(たった70行!)
import { Denops } from "https://deno.land/x/denops_std@v2.0.0/mod.ts";
import { OpenAIChat } from "npm:langchain/llms/openai";
import { PromptTemplate } from "npm:langchain/prompts";
// NOTE: export OPENAI_API_KEY="YOUR_OPENAI_API_KEY"
const llm = new OpenAIChat({});
const promptTemplate = new PromptTemplate({
inputVariables: ["input", "locale"],
template: `
以下の指定された仕様を元に、明瞭で簡潔なgit commitメッセージを生成します。
不必要な翻訳や余計な情報は除外して、gitコミットに直接使える形で提供します。
### Message Language
{locale}
### diff
{input}
`,
});
async function sendChatMessage(text: string) {
const prompt = await promptTemplate.format({ input: text, locale: "ja" });
const commitMessage = await llm.call(prompt);
return commitMessage;
}
async function runGitCommand(args: string[]): Promise<string> {
const command = new Deno.Command("git", {
args: args,
stdout: "piped",
stderr: "piped",
});
const { code, stdout, stderr } = await command.output();
if (code !== 0) {
const errorOutput = new TextDecoder().decode(stderr);
console.error(errorOutput);
throw new Error(`Git command failed: ${errorOutput}`);
}
return new TextDecoder().decode(stdout);
}
export async function main(denops: Denops): Promise<void> {
denops.dispatcher = {
async aiCommits(): Promise<void> {
try {
const gitDiffResult = await runGitCommand(["diff"]);
if (gitDiffResult === "") {
console.log("No changes to commit.");
return;
}
const commitMessage = await sendChatMessage(gitDiffResult);
const shouldcommit = await denops.call(
"input",
`Commit this? / message: ${commitMessage}) [y/n]: `
);
if (shouldCommit.toLowerCase() === "y") {
await runGitCommand(["add", "."]);
await runGitCommand(["commit", "-m", commitMessage]);
}
} catch (error) {
console.error("Error in aiCommits:", error);
}
},
};
await denops.cmd(
`command! AICommits call denops#notify("${denops.name}", "aiCommits", [])`
);
}
main関数
今回でいうmain関数です。
async aiCommits(): Promise<void> {
try {
const gitDiffResult = await runGitCommand(["diff"]);
if (gitDiffResult === "") {
console.log("No changes to commit.");
return;
}
const commitMessage = await sendChatMessage(gitDiffResult);
const shouldCommit = await denops.call(
"input",
`Commit this? / message: ${commitMessage}) [y/n]: `
);
if (shouldCommit.toLowerCase() === "y") {
await runGitCommand(["add", "."]);
await runGitCommand(["commit", "-m", commitMessage]);
}
} catch (error) {
console.error("Error in aiCommits:", error);
}
},
おおよそ、以下の流れでコードが記載されています
- Gitの変更をチェック(runGitCommand)
- コミットメッセージの生成(sendChatMessage)
- ユーザーによるコミットの確認(shouldCommit)
- 変更のステージングとコミット(runGitCommand)
それぞれの詳細を以下に述べます
git diffの取得
git diff
を取得する部分(外部コマンドの実行)には、Deno.Command
を使用しています。
async function runGitCommand(args: string[]): Promise<string> {
const command = new Deno.Command("git", {
args: args,
stdout: "piped",
stderr: "piped",
});
const { code, stdout, stderr } = await command.output();
if (code !== 0) {
const errorOutput = new TextDecoder().decode(stderr);
console.error(errorOutput);
throw new Error(`Git command failed: ${errorOutput}`);
}
return new TextDecoder().decode(stdout);
}
今回だと、引数の args
で ["diff"]
を受け取っていて、stdoutでgit diff
の結果を全て受け取っています
残りは細かいエラー処理になります。
chatGPT APIとの繋ぎ込み
LangChain部分です。
git diffからcommit messageを作成しており、プロンプトテンプレートを使用しています。
// NOTE: export OPENAI_API_KEY="YOUR_OPENAI_API_KEY"
const llm = new OpenAIChat({});
const promptTemplate = new PromptTemplate({
inputVariables: ["input", "locale"],
template: `
以下の指定された仕様を元に、明瞭で簡潔なgit commitメッセージを生成します。
不必要な翻訳や余計な情報は除外して、gitコミットに直接使える形で提供します。
### Message Language
{locale}
### diff
{input}
`,
});
async function sendChatMessage(text: string) {
const prompt = await promptTemplate.format({ input: text, locale: "ja" });
const commitMessage = await llm.call(prompt);
return commitMessage;
}
今回は、個人で使用するようなプラグインのため、プロンプトインジェクション対策などは特にしていません。
また、細かい部分で言うと、
temperature
や modelName
の指定なども省略しているので、設定できるようにするとより
ユーザが使いやすいプラグインになると思うので、余裕があれば対応してみたいなと思っています。
結果の表示とユーザからの入力受付
このコードは、denops ライブラリを使用して、ユーザーにコミットするかどうかの確認を求めています。
const shouldcommit = await denops.call(
"input",
`Commit this? / message: ${commitMessage}) [y/n]: `
);
ここで使われている denops.call 関数は、DenoプラグインからVimやNeovimの内部関数を非同期で呼び出すためのものです。
第一引数 "input" は、VimやNeovimの input() 関数を指定しています。この関数は、ユーザーからのテキスト入力をプロンプトで受け付けるために使われます。
第二引数は、表示されるプロンプトのメッセージです。ここでは Commit this? / message: ${commitMessage}) [y/n]: という文字列が使用されており、
commitMessage 変数の内容を含めたメッセージがユーザーに表示されます。
add / commit
最後にadd / commitの実行部分になります。
ユーザからの入力がyだった場合は、git add .
とgit commit -m ${commitMessage}
と実行しています
if (shouldCommit.toLowerCase() === "y") {
await runGitCommand(["add", "."]);
await runGitCommand(["commit", "-m", commitMessage]);
}
おわりに
以上、 Denops x LangChainでコミットメッセージを自動生成するvim plugin作ってみた
でした!
プラグイン自体は作ってはみたものの、細かいハンドリングを無視している部分があるので実用性があるかと言われればまだ微妙です。
(バイナリの中身送信しない・最大送信文字数決める・モデルを外部から設定できる...など)
しかし、今回Denopsを用いてのプラグイン開発が予想以上に楽だったため、また別のプラグイン開発に挑戦したいなと思いました。🎉
特にLangChainなどAI周りのライブラリがあるので生成AIでの開発効率化があれば、作ってみたいなと思ってます。
以上、Applibot Advent Calendar 2023 / Vim advent calendar 2023 の3日目の記事でした!
余談
このリポジトリの4コミット目以降はこのプラグインによってメッセージを生成してcommitしていました
(以下今回開発したプラグインによるコミットメッセージ)
参考
Discussion