AI時代におけるNeovimでのストリームレスポンス処理と表示
最近はAI関係でストリームレスポンスを扱うことが増えてきました。AIのチャットをパラパラと表示する例のアレです。
Neovimも例外ではありません。AI系のプラグインも増えてきましたが、自分でサクッと書ければカスタマイズの幅が広がって便利です。
この記事では、Neovimでストリームレスポンスを扱う方法をサンプルプログラム付きで解説します。
plenary.nvimのcurl
Neovimでcurlを使うなら、plenary.nvimが便利です。
plenary.nvimは、Neovimで使える便利関数を集めたプラグインです。「よく分からないけど依存プラグインだからインストールしている」という方も多いのでは?
今回は「vim.system
関数」とplenary.nvimで実装されている「plenary.curl
」の両方を扱います。
環境の準備
まずはplenary.nvimをインストールします。
lazy.nvimだと次のように書いてインストールできます。
require("lazy").setup({ spec = { { "nvim-lua/plenary.nvim", lazy = true }, },})
デモ用のAPIサーバーを準備
今回はデモ用のAPIサーバーとしてHonoを使ったTypeScriptのコードを用意します。
import { Hono } from "hono";import { streamText } from "hono/streaming";
const app = new Hono();
app.get("/hello", (c) => { return streamText(c, async (stream) => { await stream.write("Hello "); await stream.sleep(1000); await stream.write(`Hono!`); await stream.sleep(1000); await stream.write(`こんにちは\nこんばんは~`); });});
export default app;
Bunであれば次のコマンド1つでインストールも実行もできます。
bun run index.ts
インストールも簡単なのでこの機会に入れてみては?
ビルドも無しで手軽に実行できるため、筆者は最近めっちゃ使ってます。
実行を確認しつつ重要オプションを学ぶ
次のようにcurlを叩きます。
curl --no-buffer http://localhost:3000/hello
--no-buffer
が超重要です。テストだったら絶対出てます。ノートなら赤ペンとかで書いてます。
curlは一定量溜め込んでから表示します。今回はリアルタイムに表示したいため--no-buffer
をつけました。
ブラウザでhttp://localhost:3000/hello
のようにアクセスしても確認できます。
vim.systemで呼んでみる
vim.system
の例を書きました。
local text = ""
vim.system({ "curl", "--no-buffer", "http://localhost:3000/hello", }, { stdout = function(err, data) if data then text = text .. data vim.notify(data) end end,}, function() -- 最終結果の表示 vim.notify(text)end)
実行するにはコマンドモードで次のように入力します。
:luafile %
ちょっとずつ通知されましたね。
plenary.curlで呼んでみる
plenaryのcurlを使ってみましょう。実行時にはvim.system
との違いに注目しましょう。
local curl = require("plenary.curl")
curl.get("http://localhost:3000/hello", { raw = { "--no-buffer" }, stream = function(error, data) if data then vim.notify(data) end end, callback = function(response) vim.notify(response.body) end,})
今度は一気に通知されましたね。curlでは改行ごとにデータを受け取るようです。
不便に思うかもしれませんが、筆者の扱う範囲だとむしろ改行されているデータを受け取るケースが多いので問題なかったです。
たとえば、Vercel AI SDKのData Stream Protocolsでは次のような感じでデータが飛んできます。
0:"This"0:" is an"0:"example."e:{"finishReason":"stop","usage":{"promptTokens":20,"completionTokens":50},"isContinued":false}d:{"finishReason":"stop","usage":{"promptTokens":20,"completionTokens":50}}
そんなわけでサーバー側に次の実装を追加します。
app.get("/chat", (c) => { return streamText(c, async (stream) => { await stream.writeln(`0:"こんにちは!"`); await stream.sleep(1000); await stream.writeln(`0:"何か"`); await stream.sleep(1000); await stream.writeln(`0:"お手伝いが"`); await stream.sleep(1000); await stream.writeln(`0:"必要ですか?\\nそれとも"`); await stream.sleep(1000); await stream.writeln(`0:"ただの挨拶?"`); });});
writeln
で改行くっつけています。
追加したらもう一度サーバーを再起動します。
bun run index.ts
向き先を/chat
に変えて実行します。
local curl = require("plenary.curl")
curl.get("http://localhost:3000/chat", { raw = { "--no-buffer" }, stream = function(error, data) if data then vim.notify(data) end end, callback = function(response) vim.notify(response.body) end,})
実践的な処理にかなり近づいてきました。
パースしたりバッファに書き込むのはこの記事の最後にやります。
plenary.curlの使い方
plenary.curlについてもう少し触れておきます。
戻り値のパターン
plenary.curlは引数によって戻り値の型が変わります。ひとつずつ見ていきましょう。
callback・streamなしのパターン
単純にレスポンスだけを受け取りたい場合、次のようにcallback
やstream
を書かずに実行します。
local curl = require("plenary.curl")
local response = curl.get("http://localhost:3000/hello")vim.notify(response.body)
戻り値の型は次のようになっています。
---@class response---@field exit number The shell process exit code---@field status number The https response status---@field headers table The https response headers---@field body string The http response body
いかにもレスポンスって感じですね。同期的に実行するため、少しだけNeovimの動作が止まります。
callback・streamありのパターン
続いて、callback
やstream
を渡すパターンです。非同期で処理させたいときに使います。
local curl = require("plenary.curl")
local job = curl.get("http://localhost:3000/hello", { callback = function(response) vim.notify(response.body) end,})vim.print(job) -- 最初に表示されるのはこれ
実行するとテーブル(変数job
)が表示されます。callback
かstream
を指定すると、戻り値はplenaryのJobになるのです。
「やっぱりこのリクエストやめよう」となったときに次のように実行すればストップできます。
job:shutdown()
callback
コマンド終了時、コールバックはレスポンスを引数として受け取ります。
---@field callback? fun(response: response): nil
stream
streamはレスポンスを行ごとに受け取ります。
---@field stream? fun(error: string, data: string|nil): nil
data
が実際に受け取るデータです。
dry_runのパターン
dry_runを指定すれば、リクエストを投げずに引数だけが返ってきます。開発中に便利です。
local curl = require("plenary.curl")
local response = curl.get("http://localhost:3000/hello", { dry_run = true })vim.print(response)
実際に表示された結果は次のとおり。
{ "-sSL", "-D", "/tmp/plenary_curl_951641bb.headers", "--compressed", "-X", "GET", "http://localhost:3000/hello" }
メソッドの指定方法
ここまでの例は全部GET
でしたが、当然それ以外のメソッドも叩けます。
curl.getcurl.postcurl.putcurl.headcurl.patchcurl.delete
動的に変更したい場合はcurl.request
を使います。
local curl = require("plenary.curl")
local response = curl.request({ url = "http://localhost:3000/hello", method = "get",})vim.notify(response.body)
URLはurl
、メソッドはmethod
に書きます。筆者はこっちのほうが好きです。
bodyの渡し方
bodyの渡し方もサンプル付きで紹介します。
まずはAPIの追加。JSONを受け取って挨拶します。
app.post("/ohayo", async (c) => { const {name} = await c.req.json(); return c.text(`おはよう ${name}`);});
サンプルを追加したらもう一度サーバーを再起動します。
bun run index.ts
次のようにbody
に渡します。今回はJSON形式で渡すため、vim.json.encode
でエンコードしてから渡しています。
local curl = require("plenary.curl")
local response = curl.request({ url = "http://localhost:3000/ohayo", method = "post", body = vim.json.encode({ name = "えーたん", }),})vim.notify(response.body)
バッファにパラパラ書き込みたい
バッファにパラパラと書き込みたい場合は、バッファの最後の位置に文字列を挿入すればよいです。実装としては次のような感じ。
---@param buffer number---@param text stringfunction set_text_at_last(buffer, text) local lines = vim.split(text, "\n") vim.api.nvim_buf_set_text(buffer, -1, -1, -1, -1, lines)end
実践!
実際にカレントバッファにパラパラ書き込んでみましょう。
local curl = require("plenary.curl")
curl.get("http://localhost:3000/chat", { raw = { "--no-buffer" }, stream = function(_, data) if data then -- パース local type_id, content_json = data:match("^([^:]+):(.+)$") if type_id ~= "0" then return end
local ok, content = pcall(vim.json.decode, content_json) if not ok then return end
-- データの書き込み local lines = vim.split(content, "\n") vim.schedule(function() vim.api.nvim_buf_set_text(0, -1, -1, -1, -1, lines) end) end end, callback = function(response) vim.notify(response.body) end,})
バッファの書き込みをvim.schedule
でラップしていますね。こうしないと次のようなエラーがでます。
Error executing luv callback:lua/write_buffer.lua:17: E5560: nvim_buf_set_text must not be called in a lua loop callbackstack traceback: [builtin#36]: at 0x0105aa0e62
一部のAPIはこういったコールバックで直接呼び出せないため、一度ループを抜けた後に実行してもらえるようにスケジュールするのです。
リンク集
細かい話は実装やテストを見ましょう。
- リポジトリ
- plenary.curl
- plenary.job
- eetann/plenary-curl-sample
- 記事に出てきたサンプルコード集
「え、面倒……。自分でやったほうが楽だ」という方はvim.system
を使いましょう。
以上、Neovimでストリームレスポンスを扱う方法の解説でした。plenary.curl
だと引数を文字列ではなくテーブルのkye-valueで渡せるため、読みやすいです。
senpai.nvimというAIプラグインを開発しており、その過程で発生したアウトプットでした。