Vim で編集しながら ChatGPT と一緒に書く Vim プラグイン書いた


# はじめに

作ったやつ。これの使い方と開発メモ的なのをつらつらと。


https://github.com/takavfx/gptwriter.vim

# 使い方

思いついたら機能をいろいろ追加していくと思うので、[v0.2.0](https://github.com/takavfx/gptwriter.vim/releases/tag/v0.2.0) 現時点での使い方だけ説明しておく。

## インストール方法

### Open API Key の発行

まずは [Open AI 側の設定画面](https://platform.openai.com/account/api-keys)で、API Keyを発行する。

### インストール設定の記述

[dein.vim](https://github.com/Shougo/dein.vim) を活用していて、toml で管理している場合は以下のように記述すれば良い。

```toml:.dein.toml
[[plugins]]
repo = 'takavfx/gptwriter.vim'
depdens = 'denops.vim'
hook_add = '''
let g:gptwriter_key = <Open API Key>
'''
```

## コマンド

### :GPTWrite

単一の命令文への回答の記述。

![gptwirter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/2e9246f3-e8a1-4e77-c6bc-a479bd67edc9.gif)


```vim
:GPTWrite <命令文>
```

### :GPTWriteFromWhole

バッファ全体を読み取ってからの回答の記述。

![gptwirter_whole.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/d3a3aefe-b0b3-7b51-b31e-ed517904a38c.gif)

```vim
:GPTWriteFromWhole <(オプション)末尾に追加する命令文>
```

### :GPTWriteFromSelected

選択した行を渡して GPT に問い合わせてからの回答の記述。

![gptwirter_selected.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/122cb728-8dea-04f2-ae86-d553cb716522.gif)

```vim
:GPTWriteFromSelected <(オプション)末尾に追加する命令文>
```

### :GPTWriteTheRest

既に定義した命令文をもとに、続きをかかせるコマンド。
端的に行ってしまえば、`GPTWriteFromEntireLines` の横着版。

```vim
:GPTWriteTheRest
```

デフォルトでは `write the rest` が命令文として末尾に追加される仕様だが、設定として以下のようにすることで、変えておくことができる。

```vimscript:.vimrc
let g:gptwriter_opwords_for_writetherest = "これの続きを書いて"
```


# 開発メモ

この開発においては以下の点が焦点になった。

1. プラグインのフレームワークに [denops.vim](https://github.com/vim-denops/denops.vim) を採用。
2. OpenAPI を使った GPT モデルへのリクエスト方法。
3. そもそも Vim プラグイン開発が不慣れなことへの対応。

## プラグインのフレームワークに denops.vim を採用

もともと、このプラグインを書き始めたのも、先に投稿した[「日記作成アプリを作りつつ denops で Vim/neovim プラグイン作る勉強をした」](https://qiita.com/takavfx/items/4a2e3bf1305723de406b) を作ってる最中にアイデアが頭をよぎったため。

なので、このフレームワークを使って書くこと自体は、このフレームワークをより理解するためにも良い材料だったことが言える。

結果としても、既存の OpenAPI のアクセス方法を TypeScript で行う方法も分かったし、あまり慣れてなかった Deno Land のドキュメントの読み方とかまで分かっていろいろ収穫を得ることができた。

とりあえず巨人の方に乗っかってみるの大事。

## OpenAPI を使った GPT モデルへのリクエスト方法

これについてはさほど問題はなく10分も調べればどう動くかまでの把握は容易だたので問題はなかった。

```typescript:deps.ts
export type { Denops } from "https://deno.land/x/denops_std@v4.1.0/mod.ts";
export { OpenAI } from 'https://deno.land/x/openai@v1.2.0/mod.ts';
export * as vars from "https://deno.land/x/denops_std@v4.1.0/variable/mod.ts";
```

```typescript:gptwriter.ts
export async function get_content(denops: Denops, text: string): Promise<string> {
  const key = await vars.globals.get(denops, "gptwriter_key") as string;
  if (!key) {
    throw new Error('g:gptwriter_key is not set.');
  }

  const instance = new OpenAI(key);
  const result = await instance.createChatCompletion({
    model: 'gpt-3.5-turbo',
    messages: [
      {"role": "user", "content": text}
    ],
  });
  const content = result.choices[0].message.content as string;
  return content;
}
```

単純な問い合わせにおいては、以下のように構築すればよく、さらにこだわるのであれば上記で良い。
ここら辺が極端に少ないコード量で書けたりするのも、denops.vim によって TypeScript で Vim/Neovim プラグインをかけるおかげか。

また、さらにこだわるとすれば、

* model を切り替えられるようにする。
* messages を管理するヘルパ関数を別途用意する。
* レスポンスで帰ってきたものを処理するヘルパ関数を別途用意する。

くらい。

# Vim プラグイン開発が不慣れなことへの対応

基本的に Vim Script 等でもバッファへのアクセス等はやったことがないので、そもそもどうやって取得していけばいいかが分からなかった。

まず、結論としては、[`denops.vim` の `function`](https://deno.land/x/denops_std@v4.1.0/function/mod.ts) のドキュメントをよく読んで、そこの API を活用するが正解。

## denops/variable

OpenAPI のトークンをハードコードするわけにはいくはずがないので、グローバル変数から取得するようにする。
大体の人が .vimrc をはじめとして Vim Script で管理しているという偏見（？）があるので、それに倣って記述できるようにした。

### denops/variable/mod.ts を読み込む

deps.ts に以下のようにして読み込んでおく。

```typescript:deps.ts
export * as vars from "https://deno.land/x/denops_std@v4.1.0/variable/mod.ts";
```

### denops/variable を使う

次の様にして Vim グローバル変数から取得してくることができる。

```typescript
const key = await vars.globals.get(denops, "gptwriter_key") as string;
```

僕の場合は、[dotfiles/.vimrc の記述](https://github.com/takavfx/dotfiles/blob/v2023.3.26/.vimrc#L114-L118)の様に、特定ローカルの場所に隔離しておく場所を用意しているので、そこで安全に管理できるようにしている。


## denops/function

### denops/function/mod.ts を読み込む

deps.ts に以下のようにして読み込んでおく。

```typescript:deps.ts
export * as fn from "https://deno.land/x/denops_std@v4.1.0/function/mod.ts";
```

### denops/function を使う

例えば、現在のポインタのある現在位置を取得したい場合は [`line()`](https://deno.land/x/denops_std@v4.1.0/function/mod.ts?s=line) を使う。
```typescript
const line_num = await fn.line(denops, ".");
```

また、バッファ内の文字列を取りたい場合は [`getline()`](https://deno.land/x/denops_std@v4.1.0/function/mod.ts?s=getline) を使う。

```typescript
// ポインタがある現在位置の一行を取得したい場合
const content = await fn.getline(denops, ".");

// 選択範囲の始点・終点のラインの文字列を取得したい場合
const content = await fn.getline(denops, "'<", "'>");

// バッファ全体の文字列を取得したい場合
const content = await fn.getline(denops, 1, "$");
```

そして、文字列を追加したい場合は [`append()`](https://deno.land/x/denops_std@v4.1.0/function/mod.ts?s=append) を使う。
```typescript
// 現在のポインタの次の行を計算して、その行に書き込む準備をする。
const line_num = await fn.line(denops, ".");
const next_line_num = +line_num + 1;

// split 部分で改行文字も含めて処理するので、綺麗にバッファ内に書き込むことができる。
const content = await fn.append(denops, next_line_num, content.split(/\r?\n/g));
```

ちなみにここで `setline()` を使ってしまうと、後続の行を上書きしてしまう事もあるので、`append()` を使う方が良い。

まぁ、ここまで調べてるとわかったが、結局は Vim Script 等でできる事 (Neovim にも対応しているので全部が全部ではないが)は基本的に `function` の方でラップされているので、何も方法が分からなかったり、やり方を調べたい場合には Vim Script 等でやる方法をまずは検索かけて、それと同じ関数名を `function` のドキュメントで調べればいいという事に気づいた。


# まとめ

実際にプラグインを作りながら denops.vim のフレームワークを理解しつつ、GPT プラグインを書いたのであった。
バッファテクをもっとハックしていくともっといろいろできそうなので、GitHub の参考コードの海を漁りつつもっと試してみようと思う。