Skip to content

訊息接管

讓外掛直接產出助理訊息本體而非引導主 LLM 的 API。當外掛為某個回合宣告接管後,主 LLM 完全不會被呼叫 —— 由外掛把正文和 reasoning 寫進一個純緩衝編輯器控制代碼,核心負責把結果放進 chat、做持久化、跑正規表達式 / cleanup 後處理、觸發生命週期事件,在外掛 discard 時回滾。

僅緩衝核心

控制代碼是一個純文字 / reasoning 緩衝。它不直接操作 chat、不觸發 ST 事件、不渲染任何東西 —— 這些都由核心依控制代碼的生命週期來驅動。更高層的編輯輔助(增量追加、結構化補丁、串流管道)放在外掛層實作。編排器擴充在 public/scripts/extensions/orchestrator/editor-ops.js 提供了參考實作 —— 依外掛需要選擇複製、依賴或取代它即可。

鉤子事件

核心 Generate() 流程在 LLM 載荷完全裝配完成、但還沒分派之前,會觸發 event_types.GENERATE_TAKEOVER_DISPATCH。訂閱方透過填入 eventData.takeoverHandle 宣告接管:

js
const context = Luker.getContext();

context.eventSource.on(context.eventTypes.GENERATE_TAKEOVER_DISPATCH, async (eventData) => {
    if (!shouldTakeover(eventData)) return;

    // 對 continue / swipe / regenerate,從既有的 chat slot 讀取原始內容,
    // 讓核心在 discard 時能正確回滾。
    const { originalText, originalReasoning } = resolveOriginals(eventData.type, context.chat);

    const handle = context.createMessageEditorHandle({
        generationType: eventData.type,
        originalText,
        originalReasoning,
        abortSignal: eventData.abortSignal,
        owner: 'my-plugin',
    });
    eventData.takeoverHandle = handle;

    // 非同步驅動 editor。核心會 await handle.complete,負責推入佔位、
    // 訂閱 onUpdate 做即時重繪,並在終態把流程路由到對應清理 pipeline。
    void (async () => {
        try {
            for await (const chunk of streamFromMyBackend(eventData)) {
                handle.setText(handle.getText() + chunk);
            }
            await handle.commit();
        } catch (err) {
            if (err.name === 'AbortError' || eventData.abortSignal.aborted) {
                // 使用者點了停止。保留已經串流出來的部分,但跳過 finalize pipeline
                //  —— 見下面的「三種終態」一節。
                await handle.abort();
            } else {
                // 沒有可用 partial 的硬失敗。還原 slot 原狀。
                await handle.discard();
            }
        }
    })();
});

function resolveOriginals(type, chat) {
    if (type === 'normal') return { originalText: '', originalReasoning: '' };
    const slot = chat[chat.length - 1];
    return {
        originalText: String(slot?.mes ?? ''),
        originalReasoning: String(slot?.extra?.reasoning ?? ''),
    };
}

事件載荷

ts
type DispatchEventData = {
    type: 'normal' | 'regenerate' | 'swipe' | 'continue';   // 其他類型不觸發該事件。
    isContinue: boolean;                                     // 是否為 Continue(需保留前綴)
    forceName2: boolean;                                     // 如有需要,傳給你自己的提示詞流程
    isStreamingEnabled: boolean;                             // 使用者目前是否啟用串流
    finalPrompt: unknown;                                    // 本將送出給 LLM 的最終 prompt
    generateData: unknown;                                   // 原始 generate-data 信封(等同 GENERATE_AFTER_DATA 載荷)
    takeoverHandle: MessageEditorHandle | null;              // 訂閱方填入以宣告接管
    abortSignal: AbortSignal;                                // 接管與正常路徑都遵守此訊號
};

如果沒有訂閱方宣告接管,正常的 LLM 分派會照常進行。如果多個訂閱方都嘗試宣告,先到先得;後續的賦值會被記錄為警告。quietimpersonate 這兩種生成類型不會觸發該事件。

isStreamingEnabled 為使用者工作階段層級的偏好,來源是 isStreamingEnabled()(也就是決定主 LLM 路徑走 sendStreamingRequest 還是 sendOpenAIRequest 的同一個 flag)。如果你的外掛自行驅動 LLM 呼叫(例如在 generateTaskgenerateTaskStream 之間擇一),應尊重此值以與 UI 其他行為保持一致。帶有自身串流開關的外掛可以進一步覆寫(編排器的「使用串流傳輸」profile 開關即為一例)。

三種終態

控制代碼會進入三種終態之一,每種會觸發不同的核心清理 pipeline。選對終態很關鍵 —— 核心對每種終態跑的下游工作不同,選錯了(例如使用者點停止時呼叫 commit())會和下一回合產生 race condition。

方法外掛何時呼叫核心反應outcome.status
commit()自然完成 —— 外掛產出了最終輸出完整 finalize pipeline:觸發 MESSAGE_RECEIVED / CHARACTER_MESSAGE_RENDERED,跑正規表達式 / cleanup 後處理,經由 appendChatMessages / saveChatConditional 持久化,觸發自動化功能(autoContinue、swipe 初始化),解鎖 UIcommitted
abort()使用者中途取消 —— 保留已串流出來的內容但不持久化同步把 partial 寫進 chat slot 並重繪;跳過 finalize pipeline(不 emit、不 save、不 autoContinue);有條件地解鎖 UI(只在後續沒有新的生成已經持鎖的情況下)。partial 在本機 chat 留著,直到使用者下一次操作把它覆蓋 —— 與 ST 核心 streaming 路徑在 streamingProcessor.isStopped 為 true 時相同的取捨aborted
discard()顯式回滾 —— partial 輸出不可用,還原為 pre-takeover 狀態回滾:把佔位從 chat splice 出去(normal / regenerate)或還原原 mes / reasoning(continue / swipe);觸發 GENERATION_STOPPED;解鎖 UIdiscarded

三種終態互斥 —— 控制代碼一旦進入任一終態,再呼叫另外兩個會拋錯(editor_committed / editor_aborted / editor_discarded)。同一終態再呼叫一次是 no-op。

使用者停止時該選哪個: 優先 abort()。使用者點停止時希望保留 partial 看看停止前生成到哪了,但希望把它當成最終輸出固化下來 —— 在被 abort 的 partial 上跑完整 finalize pipeline(emit、save、autoContinue)會跟使用者接下來點的 regenerate 撞車,因為 finalize pipeline 含多處 await yield,在這些 yield 之間下一個 Generate() 會啟動。只在 partial 會誤導使用者的情況下用 discard()(例如外掛產出了一段格式壞掉的本體,與其展示不如丟掉)。

職責劃分

外掛負責:

  • 監聽 dispatch 事件,決定是否宣告接管。
  • 依 generationType 用合適的 originalText / originalReasoning 建構控制代碼。
  • 透過 setText / setReasoning 把最終的文字和 reasoning 寫進緩衝。
  • commit() / abort() / discard() 三者中恰好呼叫一個讓控制代碼進入終態。

核心負責:

  • 把佔位訊息推入 chatnormal 類型),或重用既有的 slot(regenerate / swipe / continue)。
  • 透過 addOneMessage 渲染佔位,透過 appendChatMessages 持久化。
  • 訂閱控制代碼的節流 onUpdate,讓外掛串流輸出時實現即時重繪。
  • 控制代碼進入終態後,路由到對應的清理 pipeline(詳見上面「三種終態」一節)。

核心不會做時間觸發的 abort。使用者點擊停止時,abort 訊號透過 eventData.abortSignal 傳遞,外掛應當尊重這個訊號(典型作法:在被 abort 的串流呼叫 catch 內呼叫 abort())。如果外掛忽略 abort 訊號,控制代碼會一直處於未結算狀態 —— 那是外掛 bug,核心不會兜底。

編輯器控制代碼

ts
interface CreateOpts {
    generationType: 'normal' | 'regenerate' | 'swipe' | 'continue';
    originalText?: string;            // 預設 '' —— 用於 discard 回滾與 continue 前綴校驗
    originalReasoning?: string;       // 預設 ''
    abortSignal?: AbortSignal;        // 傳入 dispatch 事件的訊號
    flushIntervalMs?: number;         // 預設 33 —— onUpdate 節流視窗
    owner?: string;                   // 預設 'unknown' —— 診斷用識別字
}

interface MessageEditorHandle {
    getText(): string;
    getReasoning(): string;

    setText(text: string): void;
    setReasoning(text: string): void;

    commit(): Promise<void>;
    abort(): Promise<void>;
    discard(): Promise<void>;
    readonly complete: Promise<{
        status: 'committed' | 'aborted' | 'discarded';
        finalText: string;
        finalReasoning: string;
    }>;

    readonly abortSignal: AbortSignal;

    // 面向核心 —— 外掛通常不需要用到。
    setOnUpdate(fn: ((text: string, reasoning: string) => void) | null): void;
}
操作行為
setText(text)整體替換文字緩衝。排入一次合併刷新(預設 33ms),呼叫核心的 onUpdate 回呼做即時重繪。text 不是字串時拋 invalid_argument;到達終態後改寫拋 editor_committed / editor_aborted / editor_discarded;generationType === 'continue' 時若新文字不保留原前綴,拋 invalid_op_for_continue
setReasoning(text)reasoning 緩衝的同語義版本,無前綴不變量。
commit()讓控制代碼進入 committed 終態,強制刷出待處理更新,以 { status: 'committed', finalText, finalReasoning } resolve complete。已 committed 時再呼叫一次是 no-op(冪等)。對已 aborted 或已 discarded 的控制代碼呼叫會拋錯。
abort()讓控制代碼進入 aborted 終態,強制刷出待處理更新(讓核心讀到最新串流緩衝),以 { status: 'aborted', finalText, finalReasoning } resolve complete。已 aborted 時再呼叫一次是 no-op(冪等)。對已 committed 或已 discarded 的控制代碼呼叫會拋錯。
discard()讓控制代碼進入 discarded 終態,取消任何待刷新,以 { status: 'discarded', finalText: originalText, finalReasoning: originalReasoning } resolve complete。已 discarded 時再呼叫一次是 no-op(冪等)。對已 committed 或已 aborted 的控制代碼呼叫會拋錯。
complete三個終態之一讓控制代碼結算時 resolve。核心 await 它來判斷該回合是否已結束。
abortSignal回傳 opts.abortSignal 傳入的同一個訊號。核心不會在 abort 時自動結算 —— 由外掛自行決定策略(典型作法:在被 abort 的串流呼叫 catch 內呼叫 abort())。
setOnUpdate(fn)核心用它訂閱節流後的緩衝更新。傳 null 取消訂閱。掛上回呼時如有待處理更新會立即刷出。

continue 前綴不變量

opts.generationType === 'continue' 時,如果傳給 setText(newText)newText 不以 opts.originalText 開頭,會拋 invalid_op_for_continue。這個檢查用來攔截最常見的坑 —— 外掛不小心把使用者希望繼續的前綴整段抹掉。做 continue 的外掛必須在前綴之上累積。

錯誤碼

TakeoverError 帶有 codemessage,以及可選的 cause / details:

錯誤碼觸發場景
invalid_generation_typeopts.generationType 缺失,或不是四個合法值之一。
invalid_argumentsetText / setReasoning 收到非字串值。
editor_committedcommit() resolve 後仍有改寫呼叫,或對已 committed 的控制代碼呼叫 abort() / discard()
editor_abortedabort() resolve 後仍有改寫呼叫,或對已 aborted 的控制代碼呼叫 commit() / discard()
editor_discardeddiscard() resolve 後仍有改寫呼叫,或對已 discarded 的控制代碼呼叫 commit() / abort()
invalid_op_for_continuecontinue 模式下 setText 會抹掉原前綴。

更高層的編輯模式

核心刻意不提供增量追加、按字元位移切片、結構化補丁套用或串流管道 —— 這些都是策略,不是狀態管理。編排器擴充在 public/scripts/extensions/orchestrator/editor-ops.js 實作了這些能力:

  • appendText(handle, text) / appendReasoning(handle, text) —— 在目前值後面銜接。
  • insertAt(handle, offset, text) / replaceRange(handle, start, end, text) / deleteRange(handle, start, end) —— 按字元位移切片。
  • applyPatch(handle, patch | patch[]) —— 套用結構化補丁(種類:replace_rangeinsert_atdelete_rangecontext_replace)。
  • patchBySemantic(handle, { find, replaceWith, occurrence? }) —— Git 風格的 context_replace(Aider / Claude Code / Cursor 風格),按位元組精確匹配,不做啟發式修復。
  • pipeFrom(handle, stream, opts?) —— 把一個 AsyncIterable<StreamChunk>(例如從 generateTaskStream 回傳的)以 mode: 'append'mode: 'replace' 的方式餵進編輯器。

其他外掛可以選擇依賴 orchestrator/editor-ops.js,也可以自行實作一套 —— 核心不在意。

三層暴露

存取方式
Layer 1import { createMessageEditorHandle, GENERATE_TAKEOVER_DISPATCH, TakeoverError } from 'public/scripts/message-takeover.js'
getContext()Luker.getContext().createMessageEditorHandle(...) + Luker.getContext().eventTypes.GENERATE_TAKEOVER_DISPATCH
擴充 ctxctx.lukerContext.createMessageEditorHandle(...) + ctx.lukerContext.eventTypes.GENERATE_TAKEOVER_DISPATCH

基於 SillyTavern 建構