fix: persist story-summary relationships and sync local changes

This commit is contained in:
2026-02-24 15:26:21 +08:00
parent 8dc7ba5fae
commit 1f3880d1d9
4 changed files with 789 additions and 257 deletions

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { oai_settings, chat_completion_sources, getChatCompletionModel, promptManager } from "../../../../openai.js";
import { oai_settings, chat_completion_sources, getChatCompletionModel, promptManager, openai_setting_names, openai_settings } from "../../../../openai.js";
import { ChatCompletionService } from "../../../../custom-request.js";
import { eventSource, event_types } from "../../../../../script.js";
import { getContext } from "../../../../st-context.js";
@@ -386,6 +386,43 @@ class CallGenerateService {
return [];
}
/**
* 临时切换到指定 preset 执行 fn执行完毕后恢复 oai_settings。
* 模式与 _withPromptToggle 一致snapshot → 覆写 → fn() → finally 恢复。
* @param {string} presetName - preset 名称
* @param {Function} fn - 要在 preset 上下文中执行的异步函数
*/
async _withTemporaryPreset(presetName, fn) {
if (!presetName) return await fn();
const idx = openai_setting_names?.[presetName];
if (idx === undefined || idx === null) {
throw new Error(`Preset "${presetName}" not found`);
}
const preset = openai_settings?.[idx];
if (!preset || typeof preset !== 'object') {
throw new Error(`Preset "${presetName}" data is invalid`);
}
let snapshot;
try { snapshot = structuredClone(oai_settings); }
catch { snapshot = JSON.parse(JSON.stringify(oai_settings)); }
try {
let presetClone;
try { presetClone = structuredClone(preset); }
catch { presetClone = JSON.parse(JSON.stringify(preset)); }
for (const key of Object.keys(presetClone)) {
oai_settings[key] = presetClone[key];
}
return await fn();
} finally {
for (const key of Object.keys(oai_settings)) {
if (!Object.prototype.hasOwnProperty.call(snapshot, key)) {
try { delete oai_settings[key]; } catch { }
}
}
Object.assign(oai_settings, snapshot);
}
}
// ===== 工具函数:组件与消息辅助 =====
/**
@@ -1183,10 +1220,15 @@ class CallGenerateService {
}
// ===== 主流程 =====
async handleRequestInternal(options, requestId, sourceWindow, targetOrigin = null) {
async handleRequestInternal(options, requestId, sourceWindow, targetOrigin = null, { assembleOnly = false } = {}) {
// 1) 校验
this.validateOptions(options);
const presetName = options?.preset || null;
// 步骤 2~9 的核心逻辑,可能包裹在 _withTemporaryPreset 中执行
const executeCore = async () => {
// 2) 解析组件列表与内联注入
const list = Array.isArray(options?.components?.list) ? options.components.list.slice() : undefined;
let baseStrategy = 'EMPTY'; // EMPTY | ALL | ALL_PREON | SUBSET
@@ -1226,6 +1268,7 @@ class CallGenerateService {
// 3) 干跑捕获(基座)
let captured = [];
let enabledIds = []; // assembleOnly 时用于 identifier 标注
if (baseStrategy === 'EMPTY') {
captured = [];
} else {
@@ -1242,6 +1285,7 @@ class CallGenerateService {
allow = new Set(order.map(e => e.identifier));
}
} catch { }
enabledIds = Array.from(allow);
const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false });
captured = await this._withPromptEnabledSet(allow, run);
} else if (baseStrategy === 'ALL_PREON') {
@@ -1255,6 +1299,7 @@ class CallGenerateService {
allow = new Set(order.filter(e => !!e?.enabled).map(e => e.identifier));
}
} catch { }
enabledIds = Array.from(allow);
const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false });
captured = await this._withPromptEnabledSet(allow, run);
} else {
@@ -1263,7 +1308,11 @@ class CallGenerateService {
}
// 4) 依据策略计算启用集合与顺序
const annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []);
let annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []);
// assembleOnly 模式下,若无显式排序引用,则用全部启用组件做 identifier 标注
if (assembleOnly && annotateKeys.length === 0 && enabledIds.length > 0) {
annotateKeys = enabledIds;
}
let working = await this._annotateIdentifiersIfMissing(captured.slice(), annotateKeys);
working = this._applyOrderingStrategy(working, baseStrategy, orderedRefs, unorderedKeys);
@@ -1279,8 +1328,40 @@ class CallGenerateService {
// 8) 调试导出
this._exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug: options?.debug, targetOrigin });
// assembleOnly 模式:只返回组装好的 messages不调 LLM
if (assembleOnly) {
// 构建 identifier → name 映射(从 promptCollection 取order 里没有 name
const idToName = new Map();
try {
if (promptManager && typeof promptManager.getPromptCollection === 'function') {
const pc = promptManager.getPromptCollection();
const coll = pc?.collection || [];
for (const p of coll) {
if (p?.identifier) idToName.set(p.identifier, p.name || p.label || p.title || '');
}
}
} catch { }
const messages = working.map(m => {
const id = m.identifier || undefined;
const componentName = id ? (idToName.get(id) || undefined) : undefined;
return { role: m.role, content: m.content, identifier: id, name: componentName };
});
this.postToTarget(sourceWindow, 'assemblePromptResult', {
id: requestId,
messages: messages
}, targetOrigin);
return { messages };
}
// 9) 发送
return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow, targetOrigin);
}; // end executeCore
if (presetName) {
return await this._withTemporaryPreset(presetName, executeCore);
}
return await executeCore();
}
_applyOrderingStrategy(messages, baseStrategy, orderedRefs, unorderedKeys) {
@@ -1409,12 +1490,45 @@ export function initCallGenerateHostBridge() {
__xb_generate_listener = async function (event) {
try {
const data = event && event.data || {};
if (!data || data.type !== 'generateRequest') return;
if (!data) return;
if (data.type === 'generateRequest') {
const id = data.id;
const options = data.options || {};
await handleGenerateRequest(options, id, event.source || window, event.origin);
return;
}
if (data.type === 'listPresetsRequest') {
const id = data.id;
const names = Object.keys(openai_setting_names || {});
const selected = oai_settings?.preset_settings_openai || '';
callGenerateService.postToTarget(
event.source || window,
'listPresetsResult',
{ id, presets: names, selected },
event.origin
);
return;
}
if (data.type === 'assemblePromptRequest') {
const id = data.id;
const options = data.options || {};
try {
await callGenerateService.handleRequestInternal(
options, id, event.source || window, event.origin,
{ assembleOnly: true }
);
} catch (err) {
callGenerateService.sendError(
event.source || window, id, false, err, 'ASSEMBLE_ERROR', null, event.origin
);
}
return;
}
} catch (e) {
try { xbLog.error('callGenerateBridge', 'generateRequest listener error', e); } catch {}
try { xbLog.error('callGenerateBridge', 'listener error', e); } catch { }
}
};
// eslint-disable-next-line no-restricted-syntax -- bridge listener; origin can be null for sandboxed iframes.
@@ -1525,6 +1639,49 @@ if (typeof window !== 'undefined') {
});
};
/**
* 全局 assemblePrompt 函数
* 只组装提示词,不调用 LLM返回组装好的 messages 数组
*
* @param {Object} options - 与 callGenerate 相同的选项格式api/streaming 字段会被忽略)
* @returns {Promise<Array<{role: string, content: string}>>} 组装后的 messages 数组
*
* @example
* const messages = await window.LittleWhiteBox.assemblePrompt({
* components: { list: ['ALL_PREON'] },
* userInput: '可选的用户输入'
* });
* // messages = [{ role: 'system', content: '...' }, ...]
*/
window.LittleWhiteBox.assemblePrompt = async function (options) {
return new Promise((resolve, reject) => {
const requestId = `assemble-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const listener = (event) => {
const data = event.data;
if (!data || data.source !== SOURCE_TAG || data.id !== requestId) return;
if (data.type === 'assemblePromptResult') {
window.removeEventListener('message', listener);
resolve(data.messages);
} else if (data.type === 'generateError' || data.type === 'generateStreamError') {
window.removeEventListener('message', listener);
reject(data.error);
}
};
// eslint-disable-next-line no-restricted-syntax -- local listener for internal request flow.
window.addEventListener('message', listener);
callGenerateService.handleRequestInternal(
options, requestId, window, null, { assembleOnly: true }
).catch(err => {
window.removeEventListener('message', listener);
reject(err);
});
});
};
/**
* 取消指定会话
* @param {string} sessionId - 会话 ID如 'xb1', 'xb2' 等)
@@ -1540,6 +1697,14 @@ if (typeof window !== 'undefined') {
callGenerateService.cleanup();
};
window.LittleWhiteBox.listChatCompletionPresets = function () {
return Object.keys(openai_setting_names || {});
};
window.LittleWhiteBox.getSelectedPresetName = function () {
return oai_settings?.preset_settings_openai || '';
};
// 保持向后兼容:保留原有的内部接口
window.LittleWhiteBox._internal = {
service: callGenerateService,

293
bridges/context-bridge.js Normal file
View File

@@ -0,0 +1,293 @@
// @ts-nocheck
import { event_types, user_avatar, getCurrentChatId } from "../../../../../script.js";
import { getContext } from "../../../../st-context.js";
import { power_user } from "../../../../power-user.js";
import { createModuleEvents } from "../core/event-manager.js";
import { xbLog } from "../core/debug-core.js";
const SOURCE_TAG = 'xiaobaix-host';
/**
* Context Bridge — 模板 iframe 上下文桥接服务
*
* 功能:
* 1. iframe 发送 iframe-ready / request-context → 插件推送上下文快照
* 2. 酒馆事件实时转发到所有模板 iframe
* 3. 延迟投递队列iframe 销毁后的事件暂存,待下一个 iframe 连接时投递
*/
class ContextBridgeService {
constructor() {
this._attached = false;
this._listener = null;
this._previousChatId = null;
/** @type {Array<{type: string, event: string, payload: object}>} */
this._pendingEvents = [];
this._events = createModuleEvents('contextBridge');
}
// ===== 生命周期 =====
init() {
if (this._attached) return;
try { xbLog.info('contextBridge', 'init'); } catch { }
try {
this._previousChatId = getCurrentChatId();
} catch { }
const self = this;
this._listener = function (event) {
try {
self._handleMessage(event);
} catch (e) {
try { xbLog.error('contextBridge', 'message handler error', e); } catch { }
}
};
// eslint-disable-next-line no-restricted-syntax -- bridge listener for iframe-ready/request-context
window.addEventListener('message', this._listener);
this._attachEventForwarding();
this._attached = true;
}
cleanup() {
if (!this._attached) return;
try { xbLog.info('contextBridge', 'cleanup'); } catch { }
try { window.removeEventListener('message', this._listener); } catch { }
this._listener = null;
this._events.cleanup();
this._pendingEvents.length = 0;
this._previousChatId = null;
this._attached = false;
}
// ===== 消息处理 =====
_handleMessage(event) {
const data = event && event.data;
if (!data || typeof data !== 'object') return;
const type = data.type;
if (type !== 'iframe-ready' && type !== 'request-context') return;
// 找到发送消息的 iframe 元素
const iframe = this._findIframeBySource(event.source);
if (!iframe) return;
const msgIndex = this._getMsgIndexForIframe(iframe);
if (msgIndex < 0) return;
// iframe-ready 时先投递积压的延迟事件
if (type === 'iframe-ready') {
while (this._pendingEvents.length > 0) {
const pending = this._pendingEvents.shift();
// eslint-disable-next-line no-restricted-syntax -- delivering queued events to newly ready iframe
try { event.source?.postMessage(pending, '*'); } catch { }
}
}
// 推送上下文快照
const snapshot = this._buildContextSnapshot(msgIndex);
// eslint-disable-next-line no-restricted-syntax -- sending context snapshot to requesting iframe
try { event.source?.postMessage(snapshot, '*'); } catch { }
}
/**
* 遍历 DOM 查找 contentWindow 匹配的 iframe
* @param {Window} source
* @returns {HTMLIFrameElement|null}
*/
_findIframeBySource(source) {
if (!source) return null;
const iframes = document.querySelectorAll('iframe.xiaobaix-iframe');
for (const iframe of iframes) {
try {
if (iframe.contentWindow === source) return iframe;
} catch { }
}
return null;
}
/**
* 从 iframe 的 DOM 位置获取消息楼层索引
* @param {HTMLIFrameElement} iframe
* @returns {number}
*/
_getMsgIndexForIframe(iframe) {
const mesBlock = iframe.closest('.mes');
if (!mesBlock) return -1;
const mesid = mesBlock.getAttribute('mesid');
if (mesid == null) return -1;
return parseInt(mesid, 10);
}
// ===== 上下文快照 =====
/**
* @param {number} msgIndex
* @returns {object}
*/
_buildContextSnapshot(msgIndex) {
const ctx = getContext();
const chat = ctx.chat || [];
const msg = chat[msgIndex];
return {
type: 'st-context',
chatId: getCurrentChatId() || null,
characterId: ctx.characterId ?? null,
characterName: ctx.name2 || '',
userName: ctx.name1 || '',
userPersona: power_user?.persona_description || '',
userAvatar: user_avatar || '',
msgIndex: msgIndex,
swipeId: msg?.swipe_id ?? 0,
totalSwipes: msg?.swipes?.length ?? 1,
totalMessages: chat.length,
isGroupChat: !!ctx.groupId,
groupId: ctx.groupId ?? null,
};
}
// ===== 事件广播 =====
/**
* 向所有活跃的模板 iframe 广播事件
* @param {string} eventName
* @param {object} payload
*/
_broadcastToTemplateIframes(eventName, payload) {
const iframes = document.querySelectorAll('.mes iframe.xiaobaix-iframe');
const message = { type: 'st-event', source: SOURCE_TAG, event: eventName, payload };
for (const iframe of iframes) {
// eslint-disable-next-line no-restricted-syntax -- broadcasting event to template iframes
try { iframe.contentWindow?.postMessage(message, '*'); } catch { }
}
}
// ===== 事件转发注册 =====
_attachEventForwarding() {
const self = this;
// ---- 消息级事件 ----
// 消息删除(截断式):原生 payload = chat.length删除后剩余消息数
this._events.on(event_types.MESSAGE_DELETED, (remainingCount) => {
self._broadcastToTemplateIframes('message_deleted', {
fromIndex: remainingCount,
});
});
// Swipe 切换:原生 payload = chat.length - 1最后一条消息索引
this._events.on(event_types.MESSAGE_SWIPED, (msgIndex) => {
const ctx = getContext();
const msg = ctx.chat?.[msgIndex];
self._broadcastToTemplateIframes('message_swiped', {
msgIndex: msgIndex,
newSwipeId: msg?.swipe_id ?? 0,
totalSwipes: msg?.swipes?.length ?? 1,
});
});
// 消息发送:原生 payload = insertAt消息索引
this._events.on(event_types.MESSAGE_SENT, (msgIndex) => {
self._broadcastToTemplateIframes('message_sent', { msgIndex });
});
// AI 回复完成:原生 payload = chat_id消息索引
this._events.on(event_types.MESSAGE_RECEIVED, (msgIndex) => {
self._broadcastToTemplateIframes('message_received', { msgIndex });
});
// 消息编辑:原生 payload = this_edit_mes_id消息索引
this._events.on(event_types.MESSAGE_EDITED, (msgIndex) => {
self._broadcastToTemplateIframes('message_edited', { msgIndex });
});
// ---- 聊天级事件 ----
// 聊天切换:原生 payload = getCurrentChatId()
this._events.on(event_types.CHAT_CHANGED, (newChatId) => {
self._broadcastToTemplateIframes('chat_id_changed', {
newChatId: newChatId,
previousChatId: self._previousChatId,
});
self._previousChatId = newChatId;
});
// 新聊天创建(含分支检测):原生 payload = 无
this._events.on(event_types.CHAT_CREATED, () => {
const ctx = getContext();
const newLength = (ctx.chat || []).length;
const isBranch = newLength > 1;
self._broadcastToTemplateIframes('chat_created', {
chatId: getCurrentChatId() || null,
isBranch: isBranch,
branchFromChatId: isBranch ? self._previousChatId : null,
branchPointIndex: isBranch ? newLength - 1 : null,
});
});
// ---- 延迟投递事件(入队,不广播)----
// 聊天删除:原生 payload = 聊天文件名(不含 .jsonl
this._events.on(event_types.CHAT_DELETED, (chatFileName) => {
self._pendingEvents.push({
type: 'st-event',
source: SOURCE_TAG,
event: 'chat_deleted',
payload: { chatId: chatFileName, timestamp: Date.now() },
});
});
// 群聊删除
this._events.on(event_types.GROUP_CHAT_DELETED, (chatFileName) => {
self._pendingEvents.push({
type: 'st-event',
source: SOURCE_TAG,
event: 'group_chat_deleted',
payload: { chatId: chatFileName, timestamp: Date.now() },
});
});
}
}
// ===== 模块级实例与导出 =====
const contextBridgeService = new ContextBridgeService();
export function initContextBridge() {
contextBridgeService.init();
}
export function cleanupContextBridge() {
contextBridgeService.cleanup();
}
// ===== 自初始化(与 call-generate-service.js 模式一致)=====
if (typeof window !== 'undefined') {
window.LittleWhiteBox = window.LittleWhiteBox || {};
window.LittleWhiteBox.contextBridge = contextBridgeService;
try { initContextBridge(); } catch (e) { }
try {
window.addEventListener('xiaobaixEnabledChanged', (e) => {
try {
const enabled = e && e.detail && e.detail.enabled === true;
if (enabled) initContextBridge(); else cleanupContextBridge();
} catch { }
});
document.addEventListener('xiaobaixEnabledChanged', (e) => {
try {
const enabled = e && e.detail && e.detail.enabled === true;
if (enabled) initContextBridge(); else cleanupContextBridge();
} catch { }
});
window.addEventListener('beforeunload', () => {
try { cleanupContextBridge(); } catch { }
});
} catch { }
}

View File

@@ -624,6 +624,11 @@ jQuery(async () => {
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-worldbook', type: 'module', src: `${extensionFolderPath}/bridges/worldbook-bridge.js` }));
} catch (e) { }
try {
if (isXiaobaixEnabled && !document.getElementById('xb-contextbridge'))
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-contextbridge', type: 'module', src: `${extensionFolderPath}/bridges/context-bridge.js` }));
} catch (e) { }
eventSource.on(event_types.APP_READY, () => {
setTimeout(performExtensionUpdateCheck, 2000);
});

View File

@@ -1028,6 +1028,70 @@ function buildFramePayload(store) {
};
}
function parseRelationTargetFromPredicate(predicate) {
const text = String(predicate || "").trim();
if (!text.startsWith("对")) return null;
const idx = text.indexOf("的", 1);
if (idx <= 1) return null;
return text.slice(1, idx).trim() || null;
}
function isRelationFactLike(fact) {
if (!fact || fact.retracted) return false;
return !!parseRelationTargetFromPredicate(fact.p);
}
function getNextFactIdValue(facts) {
let max = 0;
for (const fact of facts || []) {
const match = String(fact?.id || "").match(/^f-(\d+)$/);
if (match) max = Math.max(max, Number.parseInt(match[1], 10) || 0);
}
return max + 1;
}
function mergeCharacterRelationshipsIntoFacts(existingFacts, relationships, floorHint = 0) {
const safeFacts = Array.isArray(existingFacts) ? existingFacts : [];
const safeRels = Array.isArray(relationships) ? relationships : [];
const nonRelationFacts = safeFacts.filter((f) => !isRelationFactLike(f));
const oldRelationByKey = new Map();
for (const fact of safeFacts) {
const to = parseRelationTargetFromPredicate(fact?.p);
const from = String(fact?.s || "").trim();
if (!from || !to) continue;
oldRelationByKey.set(`${from}->${to}`, fact);
}
let nextFactId = getNextFactIdValue(safeFacts);
const newRelationFacts = [];
for (const rel of safeRels) {
const from = String(rel?.from || "").trim();
const to = String(rel?.to || "").trim();
if (!from || !to) continue;
const key = `${from}->${to}`;
const oldFact = oldRelationByKey.get(key);
const label = String(rel?.label || "").trim() || "未知";
const trend = String(rel?.trend || "").trim() || "陌生";
const id = oldFact?.id || `f-${nextFactId++}`;
newRelationFacts.push({
id,
s: from,
p: oldFact?.p || `${to}的关系`,
o: label,
trend,
since: oldFact?.since ?? floorHint,
_addedAt: oldFact?._addedAt ?? floorHint,
});
}
return [...nonRelationFacts, ...newRelationFacts];
}
function openPanelForMessage(mesId) {
createOverlay();
showOverlay();
@@ -1368,6 +1432,11 @@ async function handleFrameMessage(event) {
if (VALID_SECTIONS.includes(data.section)) {
store.json[data.section] = data.data;
}
if (data.section === "characters") {
const rels = data?.data?.relationships || [];
const floorHint = Math.max(0, Number(store.lastSummarizedMesId) || 0);
store.json.facts = mergeCharacterRelationshipsIntoFacts(store.json.facts, rels, floorHint);
}
store.updatedAt = Date.now();
saveSummaryStore();