把 BlockNote 的 AI 接上地端 Gemma:一場跟 Vercel AI SDK tool-calling 格式的硬仗

zh-TW · 位元築夢 · 2026-05-28

最近在做一個純離線的桌面筆記 App,用 BlockNote 當編輯器核心。BlockNote 有一個官方 AI 套件 @blocknote/xl-ai,可以做「改善寫作 / 簡化 / 修正拼寫 / 翻譯 / 續寫」這類選文 AI,還附帶很漂亮的 inline diff(刪除線+highlight 的就地對比)跟 Accept/Reject。官網 demo 看起來爽到不行。

我的需求很單純:把這套 AI 接上我們地端跑的 Gemma 3 27B 模型(離線、不走雲端 API)。想說官方都做好了,接個 endpoint 應該十分鐘搞定吧?

結果,這又是一場排查惡夢的開始。

(其實這功能不接也不會死,筆記照樣能用。只是我看 xl-ai 那個 diff 動畫實在太香,就想說一定要弄出來。)


先搞懂 xl-ai 是怎麼接後端的

第一步當然是查用法。翻了 xl-ai 的文件才發現,它整套 AI 流程是建在 Vercel AI SDK 上面的。它對外的接點是一個 transport——你可以塞自己的 transport 來決定「請求怎麼送到 LLM 後端」。

文件給的標準作法,是用 Vercel AI SDK 的 transport 搭一個 createOpenAICompatible adapter,指向你自己的 OpenAI-compatible endpoint,再丟給 AIExtension

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 的本質:

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

xl-ai 給 LLM 的不是「請改寫這段文字」,而是一個叫 applyDocumentOperationsfunction calling 工具,輸入是一個 operations 陣列,每個 op 形如 { type: "update"|"add"|"delete", id, block: <html> }。它期待 LLM 用 tool-calling 的格式、邊生成邊吐這個 tool 的 input JSON delta(一個字一個字補上),xl-ai 再用 parsePartialJson 邊收邊解那串還沒完成的 JSON、逐步套用。

也就是說,xl-ai 假設你後面接的是一個會乖乖做 tool-calling 的雲端大模型(GPT-4、Claude 那種)。

我們地端的 Gemma 根本不穩定 tool-calling,它只會吐純文字。 硬把 transport 接上去,等於要 xl-ai 拿純文字去跑 parsePartialJson,直接撞它內部對「同一個 op 多次 yield、partial → complete 轉換」的 race condition。我試了兩條路:

  • buffer 模式(等整段收完再一次送)
  • append-only streaming(邊收邊往後append)

兩條都掛,經典報錯是 No matching function for add

問題的本質就是格式對不上——xl-ai 是用 AI SDK 的 tool-input-delta 一個字一個字收 tool 的 input JSON,組起來像這樣:

// xl-ai 期待的:applyDocumentOperations 的 input,partial → 逐字補完
// (實際 SSE 一行一個 tool-input-delta,inputTextDelta 累積這串 JSON)
{ "operations": [{ "type": "update", "id": "blk-1$", "block": "<p>改寫中…</p>" }] }

順帶一提:每個 id 後綴 $ 是 xl-ai 的 idsSuffixed: true 慣例,告訴模型「這是 id 不是文字」。

但 Gemma 回的是這個:

改寫中的內容。

純文字。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 試這條:

// 大意:實作 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 處理。但代價是 by design 就是假流式:我得 buffer 完整段 Gemma 輸出才能組 tool call、才能 emit;長回應一打就是十幾二十秒空白,使用者只能對著沒反應的編輯器發呆。toolbar 補打字機特效也只是哄人,editor 本體還是死的。

結論:乾淨但慢,達不到真流式。放棄這條,繼續找有沒有「不用 buffer 又能繞過 tool-call」的路。


為什麼不乾脆強制 Gemma 在後端吐 JSON?

寫到這裡可能有人會想:Ollama、vLLM、llama.cpp 不是早就支援 JSON Mode / grammar-constrained decoding 了嗎(format: "json" / guided_json / GBNF grammar)?直接在後端把 Gemma 約束成輸出 xl-ai 要的 tool-call JSON 不就好了?回到第一條路。

我認真考慮過,但有幾個現實理由把它擋下來:

  1. HTML 在 JSON 字串裡很脆弱:每個 op 的 block 是 HTML 字串,模型要正確 escape "、平衡 <p>...</p>、處理 Unicode 跳脫。grammar 只能保證 JSON 是合法 JSON,管不到字串裡 HTML 的正確性。Gemma 3 27B 對 HTML 本來就普通,常見的問題是 tag 缺角或 attribute 被吃掉,grammar 救不了這層。
  2. Token 成本與生成速度:xl-ai 的 schema 包了一層 applyDocumentOperations.operations[].{type,id,block},光鷹架 token 就一堆,加上 JSON escape 額外的字,地端推論時間明顯被拉長——對「希望使用者按下去馬上看到字冒出來」的 UX 體感是負分。
  3. 就算過了,還是會撞同一面牆:看後面「同場加映」那段——就算 cloud 大模型用對 tool-call 格式,長文 inline diff 一樣不漂亮。強制 JSON 只能解決「parser 不爆」,解不掉 diff 邏輯本身對結構亂掉的長文的天花板。
  4. op-forwarder 反而更乾淨可控:後端拿純文字、自己 deterministic 組 op,沒有 JSON parse / escape 風險,測試與 debug 都簡單(這次很多 bug 就是靠後端 print + 重放 HAR 找出來的)。

簡單說:強制 JSON 是把 race / format 的責任甩給模型,Gemma 不一定接得住;就算接住,UX 還是被同樣的天花板擋下來。倒不如讓後端當「翻譯」做這件事,又便宜又可控。


官方藏了一個 manual-execution 逃生口

不想放棄那個 diff 效果,就回頭去翻 xl-ai 的 examples。一路 web fetch 翻到這份官方範例的原始碼:

範例

05-manual-execution——光名字就對了。把整份 App.tsx fetch 下來看,它示範兩種完全 bypass transport / chat / parsePartialJson 的玩法:

  1. executeOne(chunk):套用單一一個 block 修改(同步套完再延遲 accept)。
  2. Streaming with objects:用 StreamToolExecutor,自己 writer.write(operationObject) 把 operation 物件一個個餵進去。

兩種模式大概長這樣:

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 — 自己 write operation 物件
const writer = executor.writable.getWriter()
writer.write({ operation, isUpdateToPreviousOperation: false, isPossiblyPartial: true, metadata: {} })
// …邊收邊 write 多個 partial…
await writer.close()

範例還帶到 getStreamToolsProvider({ withDelays, defaultStreamTools, selectionInfo }) 怎麼產 streamTools。有個當下沒注意、後來害我很慘的細節:官方範例對 partial / complete 的 write 都是直接 call、沒 await 這個 fire-and-forget 是真流式的關鍵,先記著(坑就在後面)。

關鍵領悟:StreamToolExecutor 才是真正把 operation 套到 ProseMirror + suggest-changes plugin 上的執行端,它跟 chat / transport 完全解耦。 我根本不需要讓 Gemma 講 tool-calling——我只要自己組出 operation 物件,直接餵 executor 就好。diff、Accept/Reject 那些都是 executor + suggest-changes 在做的,照樣有。

operation 物件長這樣:

{
  "operation": { "type": "update", "id": "<block-id>", "block": "<p>改寫後的內容</p>" },
  "isUpdateToPreviousOperation": true,   // 這個 op 是不是延續/取代上一個
  "isPossiblyPartial": true,             // 內容還沒生成完(串流中)
  "metadata": {}
}

初版做出來了,但效果是假流式

照著 manual-execution 的路,第一版(也是第一個 commit 的版本)是這樣做的:

  • 前端覆寫掉 xl-ai 的 invokeAI,自己呼叫後端的純文字串流 /chat/stream
  • 收到 Gemma 的純文字後,前端自己用 \n\n 段落邊界把文字切塊、組成 update / add 的 operation 物件。
  • writer.write 餵給 StreamToolExecutor,executor 即時套用、產生 diff。

還得手動補一件事。先說一下背景:BlockNote 底層是 ProseMirror,所有狀態變更都透過一個叫 Transaction 的物件、經由 view.dispatchTransaction(tr) 進去才會套到文件上。 要讓 AI 的變更被當成「建議」(刪除線+highlight 的 diff mark、配上 Accept/Reject),而不是直接寫死到文件上,就必須在這層攔截,把 transaction 的 insert/delete/replace step 改寫成對應的 suggestion mark step。

xl-ai 註冊了 @handlewithcare/prosemirror-suggest-changes plugin 但原生是自己手動插 diff mark 的;我們繞掉那條,所以得自己把 view 的 dispatchTransaction 包一層 withSuggestChanges,AI 的變更才會被當「建議」、才有刪除線 highlight 跟 Accept/Reject:

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。

它「能動」,但效果是假流式——editor 不會即時跟著變,每次都要等 event done(整段回完)那一瞬間才一次把文字套上去,跟官網 demo 那種一個字一個字 reveal 的體感差很多。長回應的時候畫面就是定格、最後啪一下全部出現,我看了一下覺得這跟「能動」差太遠,必須處理。

後來才抓到假流式的根因:我犯了官方 example 沒犯的錯——用了 await writer.write(...)

// ❌ 假流式: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……edge case 一堆。與其在前端硬拼,不如把這層搬到後端。


把組 op 這層搬到後端,模擬 tool call

既然前端拿純文字拼 op 這層太脆,就換個分工:讓後端來組 operation。 而且後端就在 Gemma 旁邊,能邊收 token、邊吐 op,真流式比前端 buffer 完再拼自然多了。

我請後端新開一條 endpoint /chat/stream/op:它把 Gemma 的純文字接過去,在後端就邊收 delta、邊組裝成乾淨的 BlockNote operation 物件,用 SSE 吐回前端。前端就退化成一個很薄的 op-forwarder:收到一個 op 就 writer.write 餵 executor,完全不用自己解析文字。

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 那個物件格式:

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 都原生支援。

理論上很美。實際上,接下來每個 edge case 都要一個一個踩。先給個全景,再逐個拆:

  • 坑 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,自己建一個全工具都開的:

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

坑 2:要 update 還是 add + 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

「卡住 + 後面一堆原文沒動」這症狀盯著截圖猜很久,最後是靠重放 HAR 裡的真實 payload 打後端、看它到底吐了什麼 op 才破案。

測試:選 7 段文字(7 個 block)做 Improve writing。把瀏覽器存的 HAR 裡那筆 request body 撈出來,原樣重放打後端、統計每個 block 收到幾個 op:

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)

輸出(修之前):

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]:

# 改之前:用 \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,重放同一筆:

block 出現順序: [7 個 id 全中]
定稿 block 數: 7 / 7

一個字元的差別,搞了一個下午。


坑 4:段落數對不齊,Simplify 偷偷併段

切段修好後,短文正常了,但長文 Simplify 還是「改一半、後面一段段被刪」

重放 21 段 Simplify:

輸入 21 段、21 個 selection_ids
→ 只有 7 個 block 被 update,其餘 14 個沒對到

這次不是切段,是段落數本身對不齊:Simplify 把 21 段濃縮成 7 段輸出,1-to-1 自然只對得到前 7 個 block,後面 14 個殘留原文。

解法:收尾把「從頭到尾沒被 update 過」的 block 全刪掉。 這裡還有個 off-by-one——不能用「最後寫到第幾個 block」算尾段(Gemma 尾端常有多餘 \n 害游標多進一格、漏刪一個),改成實際記錄寫過哪些 block id(一個 set),收尾刪不在 set 裡的:

written_ids = set()
# ...每 yield 一個 update op 就 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 字才套用一次

// 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 設成 withDelays: false(想說後端已經在串流了,不要再加動畫)。兩個加起來就是 50 字一跳、很頓。

withDelays 改回 true,xl-ai 會在每個 diff step 之間補微小延遲,那 50 字的 chunk 就被逐字 reveal——視覺上就跟 demo 一樣字一個個冒:

const provider = aiDocumentFormats.html.getStreamToolsProvider({
  withDelays: true,                                    // ★ 開動畫,demo 預設也是 true
  defaultStreamTools: { add: true, update: true, delete: true },
})

原來那個「順」是動畫,不是真的逐 token。


全部接起來長這樣

繞了一大圈,最後長這樣:

---
config:
  layout: elk
  theme: neutral
---
flowchart TB
    User(["使用者 / BlockNote AIMenu"]) -- "選文 + 下命令" --> Invoke["invokeAI (被我們覆寫)"]
    Invoke -- "組 prompt + selection_ids/anchor_id" --> Service["chatStreamOp() 前端 service"]
    Service -- "POST /chat/stream/op (SSE)" --> Backend["後端 op-stream endpoint"]
    Backend -- "純文字 prompt" --> Gemma["地端 Gemma (只吐純文字)"]
    Gemma -- "token 串流" --> Backend
    Backend -- "邊收邊組裝 operation\n(模擬 tool call)" --> Service
    Service -- "writer.write(op)" --> Executor["xl-ai StreamToolExecutor"]
    Executor -- "套用 op" --> Suggest["withSuggestChanges (diff mark)"]
    Suggest --> Editor["編輯器:刪除線+highlight 就地 diff"]
    Editor -- "Accept / Reject" --> Done(["定稿 / 還原"])

    classDef local fill:#f9f,stroke:#333,stroke-width:2px
    classDef be fill:#ffcc00,stroke:#333,stroke-width:1px
    Gemma:::local
    Backend:::be

最終分工:

  • 選文命令(fix-spelling / improve / translate)→ 後端吐 update op,對每個選取 block 就地字級 diff;輸出段落比選取少時,多的尾端 block 收尾刪掉。
  • /ai 續寫(無選文)→ 後端吐 add op 插在游標後,逐字串流。
  • 前端 provider 三工具全開、withDelays: truedispatchTransactionwithSuggestChanges

對照一開始的死路(硬接 transport 要 Gemma tool-calling),核心轉念是:不要逼地端模型去講 xl-ai 的語言,而是在後端補一層 adapter 把純文字翻成 operation,直接餵 executor。


被公司 MAC 擋住,只能一直重打包

中間有一段很痛苦:前端的改動沒辦法用 pnpm dev 在本機測,被公司端點管控(MAC)擋掉了。每改一次前端就得完整重打包一份桌面安裝包(約 2 分鐘)+重裝才能驗證,迭代慢到懷疑人生。

後來想了個 workaround:後端那隻 Python 服務其實是獨立的本機 sidecar(跑在 localhost:7501),最後是被編成 .pyd 包進安裝包的——我沒辦法重編 .pyd,但可以用一個輕量 venv(只裝 fastapi/httpx/opencc/tiktoken,完全不碰 torch)只把那條 op-stream 路由單獨跑在 7502,再把前端打包時烤成指向 7502。這樣後端邏輯就能即時改、即時重啟測,不用每次重編 .pyd。重放 HAR 找切段 bug 也是靠這套才做得到。

(這種「接手別人基礎設施、東西又被權限/工具擋住、只能用奇技淫巧繞」的感覺,跟上次搞 PostHog pipeline 簡直一模一樣。)


真正的天花板,其實是模型不夠力

把所有程式問題修完之後,剩一個不是 bug、是模型能力的問題:

Gemma 3 27B 在長輸入下會偷懶。 重放一筆「整份文件塞在同一個 block(1647 字)」的 Improve writing,比對輸入輸出:

輸入 1647 字 → 輸出 1472 字
輸入/輸出「完全相同的結尾」= 1044 字

也就是說,Gemma 只認真改寫了開頭約 600 字,後面 1044 字一字不改、原樣 echo 回來。 diff 忠實顯示出來就變成「前段有變更、後段完全沒動」——我一開始也以為是 bug,挖出這個數據才確定不是程式問題,是模型自己擺爛。

兩個衍生狀況:

  1. 整份文件塞在單一 block:只有一個 block,後端無法切段,只能對著巨塊反覆算 diff,又慢、diff 又雜。
  2. 段落結構被模型打亂:prompt 明明寫了「保持相同段落數」,Gemma 還是會合併段落,1-to-1 對映崩掉(靠坑 4 的刪尾端兜住,但形狀還是醜)。

根治方向per-block 逐塊呼叫:選取 N 個 block 就發 N 次 LLM,每塊只丟自己那段——每塊都小、模型會完整改寫、永遠 1-to-1 不會對不齊。代價是 N 次呼叫、延遲變高,且要前後端一起改。這個留作下一階段的 enhancement。


不只是 Gemma —— 官方 demo 在類似情境下也會炸

為了確認到底是不是我們地端模型的問題,我跑去 BlockNote 官方 demo 頁 把同樣那段(含先前 AI 留下的重複碎片)全選按 Simplify 試試看——結果一樣會炸,diff 一片混亂、看不出在改什麼。

撈了一下官方 demo 發出去的 request body,那個 tool definition 證實了單一 tool + operations 陣列的設計:

"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 一個字一個字補上):

{"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 留下的重複(「並根據他們的並根據他們的意見對翻譯系統進行改進他們的並根據他們的意…」),cloud 模型再對著這個亂攤子做 Simplify、產生的 diff 自然不會漂亮——這已經不是模型強弱的問題,而是 inline diff 對「結構性亂掉的長文」本身就很難看

換句話說:我們踩到的 mapping 對不齊、diff 醜的問題,有一部分是 BlockNote 這套就地 diff 路線的天花板,不是地端 Gemma 獨有的鍋。 換成 OpenAI/Claude 也只能贏在「不會半段就 echo」,diff 在長文上一樣會掙扎。


最後想說

這趟最大的體悟:官方套件的「美好 demo」背後通常假設了一個很強的後端(這裡是會 tool-calling 的雲端大模型)。一旦環境不符合那個假設(地端小模型、只吐純文字),硬接它的 transport 只會撞一堆內部 race condition;正確姿勢是找它的逃生口(manual-execution / StreamToolExecutor),自己補一層 adapter 把兩邊格式橋接起來——然後再一個一個踩它沒幫你處理的 edge case:工具集限定、partial diff 的時機、切段符號、段落數對映、串流節流動畫……

最後功能做出來了,短中等文字體驗很不錯。剩下的長文問題,老實說不是前端或後端能再榨出來的——那是模型本身的天花板。知道天花板在哪,也算是一種收穫。