完全オフラインのデスクトップ・ノート App を作っていて、エディタの中核に BlockNote を使っています。BlockNote には公式の AI パッケージ @blocknote/xl-ai があり、「文章改善 / 簡略化 / スペル修正 / 翻訳 / 続き書き」といった選択 AI ができ、しかも綺麗な (取り消し線+highlight のその場対比)と Accept/Reject が付いてくる。公式 demo は最高に気持ちいい。

私の要件は単純:この AI を、ローカルで動かす Gemma 3 27B モデルに繋ぐ(オフライン、クラウド API は使わない)。公式が全部作ってくれてるんだから、endpoint を繋ぐだけで十分くらいで終わるだろう?

結果、これがまた一つのデバッグ地獄の始まりでした。

まず xl-ai がどうやってバックエンドに繋ぐのか理解する#

第一歩は当然、使い方を調べること。xl-ai のドキュメントを読んで分かったのは、AI フロー一式が**Vercel AI SDK の上に建っている**こと。外向きの接点は ——自分の transport を差し込んで「リクエストを LLM バックエンドへどう送るか」を決められる。

ドキュメントの標準的なやり方は、Vercel AI SDK の transport で createOpenAICompatible adapter を組み、自分の OpenAI-compatible endpoint を指し、AIExtension に渡す:

ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { AIExtension, createBlockNoteAIClient } from "@blocknote/xl-ai"

// model をローカルの OpenAI-compatible chat completions に向ける
const client = createBlockNoteAIClient({ baseURL: "http://localhost:7501/v1", apiKey: "x" })
const model  = createOpenAICompatible({ name: "local", fetch: client.fetch })
  .chatModel("google/gemma-3-27b-it")

// エディタに AIExtension を掛ける;transport は既定(内部で model 経由の tool-calling リクエストを発行)
useCreateBlockNote({ extensions: [AIExtension({ model })] })

至極まとも。うちのバックエンドにはちょうど OpenAI-compatible の chat completions が一本あるから、繋げば終わりだろう。

そうして私は一つ目の壁に真っ向からぶつかりました。

xl-ai はあなたの LLM が tool-calling できると決めてかかる#

transport を繋いだ途端、AI はまったく動かないか、内部エラーを直に吐く。掘って初めて、xl-ai の pipeline の本質が分かった:

text
transport → chat.sendMessage → tool-input-delta → parsePartialJson → operation

xl-ai が LLM に渡すのは**「この文章を書き直して」ではなく**、applyDocumentOperations という で、入力は operations 配列、各 op は { type: "update"|"add"|"delete", id, block: <html> } の形。LLM が tool-calling の形式で、生成しながらこのツールの input JSON delta を(一文字ずつ)吐き、xl-ai が でその未完成の JSON を受けながら解して逐次適用する、と期待している。

つまり xl-ai は、後ろに繋ぐのが素直に tool-calling するクラウド大モデル(GPT-4、Claude の類)だと仮定している。

ところがうちのローカル Gemma はまるで tool-calling が安定せず、純テキストしか吐かない。 無理やり transport を繋ぐのは、xl-ai に純テキストで parsePartialJson を走らせるに等しく、その内部の「同じ op を複数回 yield、partial → complete 変換」まわりの に真っ向から突っ込む。二つの道を試した:

  • buffer モード(全部受け切ってから一度に送る)
  • append-only streaming(受けながら後ろへ append)

どちらも死に、お決まりのエラーは No matching function for add

問題の本質はフォーマットの不一致——xl-ai は AI SDK の tool-input-delta で tool の input JSON を一文字ずつ受け、組むとこうなる:

jsonc
// xl-ai が期待する:applyDocumentOperations の input、partial → 一文字ずつ補完
// (実際の SSE は一行に一つの tool-input-delta、inputTextDelta がこの JSON を累積)
{ "operations": [{ "type": "update", "id": "blk-1$", "block": "<p>書き直し中…</p>" }] }

NOTE

id の末尾 $ は xl-ai の の慣習で、「これは id であって文字ではない」とモデルに伝える。

でも Gemma が返すのはこれ:

text
書き直し中の内容。

純テキスト。これを parsePartialJson に解かせると undefined の山しか出ず、partial→complete の race と合わさって、No matching function for add

結論は明快:xl-ai は構造化 tool call を求め、Gemma は純テキストしかくれない。transport のこの道はローカルモデルでは死んでいる。

じゃあ自前の ChatTransport で tool-call するフリをすれば?これも死#

Gemma が tool-call できないなら、フロント側で自前の ChatTransport を書き、純テキストを xl-ai が求める tool call 形式に自分で包めばいいのでは?lib/blocknote-ai-transport.ts を作ってこの道を試した:

ts
// 大意:Vercel AI SDK の ChatTransport を実装し、内部で既存の /chat/stream(純テキスト)を呼び、
// 全段を受け切ってから applyDocumentOperations tool call の形で xl-ai へ吐く。
class BlocknoteAITransport implements ChatTransport<UIMessage> {
  async *sendMessages({ messages, body }) {
    // 1. 純テキスト endpoint を呼び、全出力を buffer する
    let buf = ""
    for await (const delta of chatStream({ userPrompt: build(messages) })) {
      buf += delta
      // (ついでに toolbar に「タイプライター」の偽アニメを出して視覚フィードバックにできる)
    }
    // 2. buffer 完了、xl-ai が期待する applyDocumentOperations tool call を組んで一度に吐く
    const toolCallId = crypto.randomUUID()
    yield { type: "tool-input-start", toolCallId, toolName: "applyDocumentOperations" }
    yield { type: "tool-input-delta", toolCallId, inputTextDelta: JSON.stringify({
      operations: parseTextIntoOps(buf),   // 純テキストを自分で切って operations 配列に組む
    })}
    yield { type: "tool-input-available", toolCallId }
    yield { type: "finish" }
  }
}

この道は綺麗——parsePartialJson が受けるのは正規の JSON で race も踏まず、xl-ai は普通の tool-call として扱う。だが代償は、設計上そもそも偽ストリーム:Gemma の全段を buffer し切らないと tool call を組めず、emit できない;長い応答なら十数〜二十秒の空白で、ユーザは反応のないエディタを眺めるしかない。toolbar のタイプライター演出も人を誤魔化ごまかすだけで、エディタ本体は死んでいる。

結論:綺麗だが遅い、真のストリームには届かない。この道を諦め、「buffer 不要で、かつ tool-call を回避できる」道を探し続ける。

いっそバックエンドで Gemma に JSON を吐かせないのはなぜ?#

ここまで書くと、こう思う人もいるかも:Ollama、vLLM、llama.cpp はとっくに に対応してる(format: "json" / guided_json / GBNF grammar)じゃないか?と。バックエンドで Gemma を xl-ai の tool-call JSON に制約すればいい——最初の道へ逆戻り。真剣に考えたが、現実的な理由がいくつかそれを止めた:

  1. JSON 文字列の中の HTML は脆い:各 op の block は HTML 文字列で、モデルは " を正しく escape し、<p>...</p> を釣り合わせ、Unicode エスケープを処理せねばならない。grammar は「JSON が合法 JSON である」ことしか保証できず、文字列の中の HTML の正しさは面倒を見ない。Gemma 3 27B は HTML がそもそも並で、tag が欠けたり attribute が食われたりが定番、grammar はこの層を救えない。
  2. トークンコストと生成速度:xl-ai の schema は applyDocumentOperations.operations[].{type,id,block} という一層を被せていて、足場トークンだけで山盛り、加えて JSON escape の余分な字で、ローカル推論時間が明らかに伸びる——「押したら即座に字が出てほしい」という UX 体感にはマイナス。
  3. 通っても、同じ壁に当たる:後の「同時上映」の段を見て——クラウド大モデルが正しい tool-call 形式を使っても、長文の inline diff はやっぱり綺麗じゃない。JSON 強制は「parser が爆発しない」を解くだけで、構造の崩れた長文に対する diff ロジック自体の天井は解けない。
  4. op-forwarder のほうがむしろ綺麗で制御しやすい:バックエンドが純テキストを取り、自分で deterministic に op を組む、JSON parse / escape のリスクなし、テストも debug も簡単(今回のバグの多くは、バックエンド print + HAR 再生で見つけた)。

要は:JSON 強制は race / format の責任をモデルに丸投げする行為で、Gemma が受け止められるとは限らない;受け止めても、UX は同じ天井に阻まれる。ならバックエンドを「翻訳」役にしてこれをやらせるほうが、安くて制御できる。

公式が manual-execution という逃げ道を隠していた#

あの diff 効果を諦めたくなくて、xl-ai の examples を漁りに戻った。web fetch を辿って、この公式サンプルのソースに辿たどり着いた:

サンプル

05-manual-execution——名前からしてもう当たり。App.tsx を丸ごと fetch して読むと、transport / chat / parsePartialJson を完全にバイパスする二つのやり方を示していた:

  1. executeOne(chunk):単一 block の変更を適用(同期で適用してから遅延 accept)。
  2. Streaming with objects: を使い、writer.write(operationObject) で operation 物件を一つずつ流し込む。

二つのモードはだいたいこう:

ts
import { aiDocumentFormats, StreamToolExecutor } from "@blocknote/xl-ai"

const provider = aiDocumentFormats.html.getStreamToolsProvider({ withDelays: true })
const tools    = provider.getStreamTools(editor, selectionInfo)
const executor = new StreamToolExecutor(tools)

// モード 1:executeOne — 一度に一つ op を適用(同期で直してから accept)
await executor.executeOne({ type: "update", id: "blk-1", block: "<p>直したもの</p>" })

// モード 2:streaming with objects — operation 物件を自分で write
const writer = executor.writable.getWriter()
writer.write({ operation, isUpdateToPreviousOperation: false, isPossiblyPartial: true, metadata: {} })
// …受けながら複数の partial を write…
await writer.close()

サンプルは getStreamToolsProvider({ withDelays, defaultStreamTools, selectionInfo }) がどう streamTools を作るかにも触れている。

WARNING

当時見落として後で私をひどい目に遭わせた細部がある:公式サンプルは partial / complete の write をすべて直接 call、await していない。この fire-and-forget こそ真のストリームの鍵——先に覚えておいて(わなは後で来る)。

決定的な気づき:StreamToolExecutor こそが operation を + suggest-changes plugin へ実際に適用する実行端で、chat / transport とは完全に分離している。 Gemma に tool-calling を喋らせる必要はまるでない——自分で operation 物件を組んで、executor に直接流し込めばいい。diff や Accept/Reject は executor + suggest-changes がやってくれるので、そのまま付いてくる。

operation 物件はこう:

jsonc
{
  "operation": { "type": "update", "id": "<block-id>", "block": "<p>書き直し後の内容</p>" },
  "isUpdateToPreviousOperation": true,   // この op が前の op の続き/置換かどうか
  "isPossiblyPartial": true,             // 内容がまだ生成し切れていない(ストリーム中)
  "metadata": {}
}

初版はできた、でも効果は偽ストリーム#

manual-execution の道に沿って、第一版(最初の commit の版)はこうした:

  • フロントが xl-ai の invokeAI を上書きし、自分でバックエンドの純テキストストリーム /chat/stream を呼ぶ。
  • Gemma の純テキストを受けたら、フロント自身が \n\n の段落境界でテキストを切りupdate / add の operation 物件を組む。
  • それを writer.writeStreamToolExecutor に流し、executor が即時適用して diff を出す。

さらに手で一つ補う必要があった。まず背景:BlockNote の土台は ProseMirror で、あらゆる状態変更は という物件を、view.dispatchTransaction(tr) 経由で通して初めて文書に適用される。 AI の変更を(文書に直接書き込むのでなく)「提案」(取り消し線+highlight の diff mark、Accept/Reject 付き)として扱わせるには、この層で横取りし、transaction の insert/delete/replace step を対応する suggestion mark step に書き換えねばならない。

xl-ai は plugin を登録しているが、素の実装は自分で手動で diff mark を差す方式;私たちはその道を迂回うかいしたので、自分で view の dispatchTransactionwithSuggestChanges で一層包む必要がある。そうして初めて AI の変更が「提案」扱いになり、取り消し線 highlight と Accept/Reject が付く:

ts
import { withSuggestChanges } from "@handlewithcare/prosemirror-suggest-changes"
import { AIExtension } from "@blocknote/xl-ai"

export function installManualAiRunner({ editor }) {
  // 1. view の dispatch を永続的に suggest-changes 版へ包む(AI 変更 → 提案 mark)
  const view = editor.prosemirrorView
  view.setProps({ dispatchTransaction: withSuggestChanges(view.props.dispatchTransaction) })

  // 2. AIExtension の invokeAI / abort を上書きし、自前の manual フローへ回す
  const aiExt = editor.getExtension(AIExtension)
  aiExt.invokeAI = async (opts) => { await runManualAi({ editor, aiExt, opts }) }
  aiExt.abort    = () => { cancelRef.current?.(); aiExt.closeAIMenu(); return Promise.resolve() }
}

注意:この版には最初から最後まで tool-calling 形式が一切ない。 純テキストが入ってきて、フロントが手で op を組む。

「動く」けれど、効果は偽ストリーム——エディタは即時に変わらず、毎回 event done(全段が返り切る)その瞬間を待って一度に文字を適用する、公式 demo の一文字ずつ reveal する体感とは大違い。長い応答だと画面は静止して、最後にパッと全部出る。これは「動く」からは程遠いと思い、必ず直すことにした。

後でようやく偽ストリームの根因を掴んだ:公式 example が犯さなかった過ちを私は犯した——await writer.write(...) を使った。

ts
// ❌ 偽ストリーム:await が各 write を直列化し、同じ microtask 鎖に詰まり、全部が最後にまとめて適用される
for await (const op of ops) {
  await writer.write(op)
}

// ✅ 真ストリーム:fire-and-forget、partial op が来た瞬間に executor が即時適用(公式サンプルの書き方)
onOp: (op) => {
  void writer.write(op).catch((e) => console.warn("write op failed:", e))
}

公式サンプルは partial / complete の write をすべて直接 call、await しない(fire-and-forget)ので、各 partial op が即時に executor で適用される;私はあの await を足したせいで、全 write が同じ鎖に詰まり、結果すべてが最後にまとめて適用 → 偽ストリーム。void writer.write(op) に変えて初めて、逐次適用の感触が出てきた。

だが write を await しなくなっても、「フロントが純テキストから自分で operation を組む」この層はやはり脆い:partial / 完成のタイミングを自分で掴み、block 境界を自分で処理し、どの段がどの block かを自分で決め……エッジケースだらけ。フロントで無理やり組むより、この層をバックエンドへ移そう。

op を組む層をバックエンドへ移し、tool call を装う#

フロントが純テキストから op を組む層が脆すぎるなら、役割分担を変える:op はバックエンドに組ませる。 しかもバックエンドは Gemma のすぐ隣にいて、token を受けながら op を吐ける——真のストリームが、フロントで buffer し切ってから組むより遥かに自然。

バックエンドに新しい endpoint /chat/stream/op を開いてもらった:Gemma の純テキストを受け、バックエンドで delta を受けながら、綺麗な BlockNote operation 物件へ組み立て でフロントへ吐き返す。フロントは薄い op-forwarder に退化する:op を一つ受けたら writer.write で executor に流すだけ、テキスト解析は一切不要。

ts
chatStreamOp(req, {
  onOp: (op) => { void writer.write(op) },          // バックエンドが op を一つ吐いたら forward
  onDone: async () => { await writer.close(); await executor.finish() },
})

SSE イベント:status(モデル読み込み状態)/ op(一つの operation)/ done / error。バックエンドが op を組む核心は、Gemma の delta を xl-ai のあの物件形式に包むこと:

python
def _op_event(block_id: str, html: str, is_update: bool, partial: bool) -> str:
    data = {
        "operation": {"type": "update", "id": block_id, "block": html},
        "isUpdateToPreviousOperation": is_update,   # 最初の op=False、以降=True(同じ塊の続き)
        "isPossiblyPartial": partial,               # ストリーム中=True、定稿=False
        "metadata": {},
    }
    return f"event: op\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

# メインループ大意:Gemma の delta を受けながら、今までに累積した文字を「成長中の block」として吐く
async for line in resp.aiter_lines():
    delta = parse_openai_delta(line)
    cur_text += delta
    yield _op_event(cur_id, f"<p>{cur_text}</p>", not is_first_op, partial=True)
    is_first_op = False

これがタイトルのあの「tool call」の正体——Gemma が本当に tool-calling するのではなく、バックエンドがあの構造化フォーマットに偽装ぎそうしている(純テキストを operation の流れへ翻訳して)、xl-ai 純正の executor に流し込む。op はバックエンドが構築するので綺麗、バックエンドが生成しながら吐くので真ストリーム、executor を通すので diff / Accept-Reject もすべて純正で付いてくる。

理論上は美しい。実際は、この先の各エッジケースを一つずつ踏むことになる。まず全景、それから一つずつ:

  • わな 1 — No tool can handle update:xl-ai の既定 provider はツール集をコマンドに束縛(continue は add だけ、improve は update だけ…)、うちの「バックエンドは常に update を吐く」と噛み合わない。
  • 罠 2 — update vs add+delete:diff 戦略の選択——字級のその場 diff塊まるごと置換 か、二つとも地雷あり。
  • 罠 3 — \n\n vs \n:バックエンドの区切り文字と LLM の実際の出力習慣が食い違う → 全部が最初の block に詰まる。
  • 罠 4 — 段落数が合わない:Simplify は段を併合、Improve は段を拡張 → LLM の出力段落数 ≠ 選択 block 数。
  • 罠 5 — ストリームが滑らかでない:xl-ai のツール内は50 字ごとに一回しか適用しない + 私が withDelays アニメを切った。

罠 1:executor がバックエンドの吐くツールを知らない#

バックエンド op-stream を繋いだ後、最初に試した /ai(選択なし・純続き書き)でこれが出た。

破案点:xl-ai の provider は**「コマンド限定」——続き書きコマンドは add ツールしか開かない。だがうちのバックエンドは全コマンドで update op を吐く**ので、executor の手元に update ツールがなく「update を処理できるツールがない」と投げる。

解法:xl-ai の限定 provider を使わず、全ツールを開いたものを自分で作る:

ts
getStreamToolsProvider({ defaultStreamTools: { add: true, update: true, delete: true } })

罠 2:updateadd + delete か?#

選択改稿には二つのやり方があり、それぞれ地雷:

  • update その場改:xl-ai が字級 inline diff(原文取り消し線、新文 highlight、その場対比)をする——まさに demo のあの効果。だがストリーム途中(isPossiblyPartial=true)は保守的で——後続 token がまた原文に一致するか分からないので、原文の末尾を先に消すのを恐れ、op が締まる(isPossiblyPartial=false)まで削除を commit しない。表に出ると「最初の数文は普通に diff、途中で止まり、残り一段は done まで待ってドバッと出る」——真ストリームに偽ストリームが継ぐ。
  • add + delete:新内容を新 block として挿入し、原 block を削除。安定、ストリームは滑らか、でも block 級(新内容が塊で現れ、旧が塊で消える)で、字級のその場対比ではない。

行き来して両方試し、最後は update を選んだ(あのその場対比の UX が欲しかった)。/ai 続き書きは add の純挿入で「原文を消すかどうか」の迷いがないぶん、ずっと滑らかだったので、カーソルの後ろに add で挿す形に固定。

罠 3:一文字に一日午後を費やした(\n\n vs \n)#

「止まる + 後ろに原文が大量に残る」この症状はスクショを睨んで長くすい測したが、最後は の中の本物の payload をバックエンドに打ち、実際にどんな op を吐いたか見て破案した。

テスト:7 段のテキスト(7 個の block)を選んで Improve writing。ブラウザ保存の HAR からその request body を取り出し、原様に再生してバックエンドへ打ち、各 block が何個 op を受けたか集計:

python
import json, httpx, collections

body = json.loads(open("payload.txt").readline())          # HAR から取り出した request body
per  = collections.Counter()
with httpx.stream("POST", "http://127.0.0.1:7502/chat/stream/op", json=body) as r:
    ev = None
    for line in r.iter_lines():
        if line.startswith("event:"): ev = line[6:].strip()
        elif line.startswith("data:") and ev == "op":
            op = json.loads(line[5:])["operation"]
            per[op["id"][:8]] += 1
print(per)

出力(修正前):

text
block 更新順: [('32671a2a', 425)]      ← 425 個の op が全部 1 番目の block に
定稿した block 数: 1                     ← 残り 6 個の block は op を一つも受けていない

全出力が最初の block に詰まり、後ろの 6 個の block は原文のまま。 原因はあきれるほど単純:バックエンドは空行 \n\n で段を切って block を替えるが、Gemma の出力段落は単一の \n で区切られている(入力形式に合わせて)、永遠に \n\n が来ない → 永遠に block を替えない → 全部 block[0] に詰まる:

python
# 修正前:\n\n で切る → Gemma 出力に \n\n がない → while に永遠に入らない → block を替えない
while "\n\n" in pending:
    head, pending = pending.split("\n\n", 1)
    ...

# 修正後:単一の \n で切り、各段を次の selection_id に対応;空行は飛ばし、最後の塊は併合
while "\n" in pending:
    head, rest = pending.split("\n", 1)
    head = head.strip()
    if not head:                                   # 空行 → block を替えない
        pending = rest; continue
    if block_idx >= len(req.selection_ids) - 1:    # 最後の塊:残りを全部併合
        pending = head + " " + rest; break
    yield _op_event(cur_id, f"<p>{head}</p>", not is_first_op, partial=False)  # この段を定稿
    written_ids.add(cur_id)
    block_idx += 1
    cur_id = req.selection_ids[block_idx]           # 次の選択 block へ替える
    is_first_op = True
    pending = rest

区切りを \n\n から単一 \n に変え、同じ payload を再生:

text
block 出現順: [7 個の id 全部ヒット]
定稿 block 数: 7 / 7

一文字の違いに、一日午後を費やした。

罠 4:段落数が合わない、Simplify がこっそり段を併合する#

区切りを直すと短文は正常になったが、長文 Simplify はまだ「途中まで改稿、後ろは一段ずつ削除」

21 段の Simplify を再生:

text
入力 21 段、21 個の selection_ids
→ update された block は 7 個だけ、残り 14 個は対応せず

今度は区切りではなく、段落数そのものが合わない:Simplify が 21 段を 7 段の出力に凝縮するので、1-to-1 では自然に前 7 個の block しか対応せず、後ろ 14 個に原文が残る。

解法:締めに「最初から最後まで一度も update されていない」block を全部削除する。 ここにも off-by-one がある——「最後に何番目の block まで書いたか」で末段を計算してはいけない(Gemma の末尾はよく余分な \n があり、カーソルが一つ余計に進んで一つ削り漏らす)ので、実際に書いた block id を記録する(一つの set)に変え、締めに set にないものを削る:

python
written_ids = set()
# ...update op を yield するたびに written_ids.add(cur_id)...
for sid in selection_ids:
    if sid not in written_ids:
        yield delete_event(sid)   # 書かれていないものは一律削除、原文残りを防ぐ

検証:21 個の selection、出力 3 段 → update 3 + delete 18 = 21 をカバー、残りゼロ。

罠 5:「滑らかさ」は実はアニメで、本当の逐 token ではない#

diff は合ったが、実測すると字が「塊ごとにパッと出る」感じで、公式 demo のあの一つずつ湧く滑らかさがない。

原因:xl-ai の update/add ツール内部にスロットルがある——block 内容が約 50 字伸びるごとに一回しか適用しない:

ts
// xl-ai update tool の execute 内(スロットル大意)
let r = 50
return { execute: async (op) => {
  if (op.isPossiblyPartial) {
    const len = JSON.stringify(op.block).length
    if (len < r) return                  // 閾値まで伸びていない → スキップ、適用しない
    r = len + 50                         //  setpoint を +50 上げる
  } else { r = 50 }                      // 非 partial(定稿)は閾値をリセット
  // …diff を計算、suggestion mark を付け、選択的に await でアニメ遅延
  for (const step of diffSteps) {
    if (opts.withDelays) await sleep()   // ★ withDelays:false はこの行がない → 50 字を一度に硬適用
    applyStep(step)
  }
}}

しかも私は provider を にしていた(バックエンドがもうストリームしてるから、これ以上アニメを足すな、と思って)。二つ合わさって 50 字で一跳ね、とてもカクつく。

withDelaystrue に戻すと、xl-ai は各 diff step の間に微小な遅延を挟み、その 50 字の chunk が一文字ずつ reveal——見た目は demo と同じく字が一つずつ湧く:

ts
const provider = aiDocumentFormats.html.getStreamToolsProvider({
  withDelays: true,                                    // ★ アニメ ON、demo の既定も true
  defaultStreamTools: { add: true, update: true, delete: true },
})

あの「滑らかさ」はアニメであって、本当の逐 token ではなかった。

全部繋ぐとこうなる#

大きく回り道して、最後はこうなった:

最終的な役割分担:

  • 選択コマンド(fix-spelling / improve / translate)→ バックエンドが update op を吐き、各選択 block にその場字級 diff;出力段落が選択より少ない時、余った末尾 block は締めに削除。
  • /ai 続き書き(選択なし)→ バックエンドが add op をカーソルの後ろに挿し、一文字ずつストリーム。
  • フロント provider は三ツール全開、withDelays: truedispatchTransactionwithSuggestChanges で包む。

最初の袋小路(transport を無理に繋いで Gemma に tool-calling させる)と対照すると、核心の発想転換は:ローカルモデルに xl-ai の言語を喋らせるのでなく、バックエンドに純テキストを operation へ翻訳する adapter を一層足し、executor へ直接流し込む。

会社の MAC に阻まれ、ひたすら再パッケージ#

途中、とても辛い一段があった:フロントの変更を pnpm dev でローカル検証できず、会社の に塞がれた。フロントを一回直すたびにデスクトップ・インストーラを丸ごと再パッケージ(約 2 分)+再インストールしないと検証できず、反復が遅すぎて人生を疑うレベル。

そこで workaround を思いついた:バックエンドのあの Python サービスは実は独立したローカル (localhost:7501 で走る)で、最終的に に固められてインストーラに入る——.pyd は再コンパイルできないが、軽量 venv(fastapi/httpx/opencc/tiktoken だけ入れ、torch には一切触れない)であの op-stream ルートだけを単独で 7502 に走らせ、フロントをビルド時に 7502 を指すよう焼き込める。これでバックエンドロジックは即時に直して即時に再起動して検証でき、毎回 .pyd を再コンパイルせずに済む。HAR を再生して区切りバグを見つけたのも、この仕組みのおかげ。

本当の天井は、実はモデルの力不足#

すべてのプログラム問題を直し終えて、残ったのはバグではなく、モデルの能力の問題:

Gemma 3 27B は長い入力で手を抜く。 「文書まるごとを同じ一つの block に詰めた(1647 字)」Improve writing を一件再生し、入出力を突き合わせた:

text
入力 1647 字 → 出力 1472 字
入力/出力の「完全に同じ末尾」= 1044 字

つまりGemma は冒頭の約 600 字だけ真面目に書き直し、後ろの 1044 字は一字も変えず原様に echo で返した。 diff が忠実に映すと「前段は変更、後段はまったく手つかず」になる——最初は私もバグかと思ったが、このデータを掘り出して初めて、プログラムの問題ではなくモデルが自分でサボっているのだと確信した。

派生する二つの状況:

  1. 文書まるごとが単一の block:block が一つだけで、バックエンドは段を切れず、巨大な塊に対して繰り返し diff を計算するしかない、遅くて diff も雑。
  2. 段落構造をモデルに崩される:prompt に「同じ段落数を保て」と書いてあるのに、Gemma はやはり段を併合し、1-to-1 対応が崩れる(罠 4 の末尾削除で凌ぐが、形はやはり醜い)。

根治の方向per-block の逐塊呼び出し:N 個の block を選んだら LLM を N 回呼び、各塊は自分の一段だけ渡す——各塊が小さく、モデルは完全に書き直し、常に 1-to-1 でずれない。代償は N 回の呼び出し、レイテンシ増、しかも前後端を一緒に直す必要。これは次段階の enhancement に残す。

Gemma だけじゃない — 公式 demo も似た状況で爆発する#

そもそも本当にうちのローカルモデルの問題なのか確かめるため、BlockNote の 公式 demo ページ へ行き、同じ一段(以前 AI が残した重複の断片を含む)を全選択して Simplify を押してみた——結果は同じく爆発、diff はぐちゃぐちゃで何を変えているのか分からない。

公式 demo が送る request body を取り出すと、あの tool definition が「単一 tool + operations 配列」の設計を裏付けた:

jsonc
"toolDefinitions": {
  "applyDocumentOperations": {
    "inputSchema": {
      "type": "object",
      "properties": {
        "operations": {
          "type": "array",
          "items": { "anyOf": [
            { "type": "object", "description": "Update a block", "properties": {
                "type": { "enum": ["update"] },
                "id":   { "type": "string", "description": "id of block to update" },
                "block":{ "type": "string", "description": "html of block (MUST be a single HTML element)" }
            }, "required": ["type", "id", "block"] }
            // ...add / delete も同じ schema
          ]}
        }
      },
      "required": ["operations"]
    }
  }
}

input の partial ストリームはこう(一行に一つ tool-input-deltainputTextDelta が一文字ずつ補完):

jsonc
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"目"}
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"的"}
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"與"}
// ...一文字ずつ operations JSON 全体を累積
{"type":"tool-input-available","toolCallId":"call_xxx","toolName":"applyDocumentOperations"}
{"type":"finish-step"} / {"type":"finish","finishReason":"tool-calls"}

そして私が提出した selectedBlocks の内容自体、すでに以前 AI が残した重複を含んでいた(「そして彼らのそして彼らの意見に基づいて翻訳システムを改善する彼らのそして彼らの…」)、クラウドモデルがこの散らかりに対して Simplify をしても、出る diff が綺麗にならないのは道理——これはもうモデルの強弱の問題ではなく、inline diff が「構造的に崩れた長文」に対してそもそも見苦しい、という話だ。

IMPORTANT

言い換えれば:私たちが踏んだ mapping のずれ、diff の醜さの問題は、一部は BlockNote のこのその場 diff 路線の天井であって、ローカル Gemma 独自のせいではない。 OpenAI/Claude に替えても勝てるのは「途中で echo しない」だけで、diff は長文で同じく苦しむ。

最後に#

この旅の一番の学び:公式パッケージの「美しい demo」の裏には、たいてい非常に強いバックエンドが前提されている(ここでは tool-calling できるクラウド大モデル)。環境がその前提に合わない(ローカルの小モデル、純テキストしか吐かない)と、その transport を無理に繋いでも内部の race condition の山に突っ込むだけ;正しい姿勢はその逃げ道(manual-execution / StreamToolExecutor)を探し、自分で adapter を一層足して両側のフォーマットを橋渡しすること——それから、向こうが面倒を見てくれなかったエッジケースを一つずつ踏む:ツール集の限定、partial diff のタイミング、区切り文字、段落数の対応、ストリームのスロットルアニメ……

最後に機能はできて、短〜中くらいのテキストの体験はとても良い。残る長文の問題は、正直、フロントやバックがこれ以上絞り出せるものではない——それはモデルそのものの天井だ。天井がどこにあるかを知るのも、一つの収穫ではある。