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 呼叫時,如果需要決定「是否即時渲染 token」,應尊重此值——開了就走 generateTaskStream,關了就走 generateTask。底層傳輸(SSE / 一次性 POST)由 generateTask 依命名預設的 stream_openai 單獨決定,與此信號獨立。

三種終態

控制代碼會進入三種終態之一,每種會觸發不同的核心清理 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 建構