diff --git a/call-generate-service.js b/call-generate-service.js new file mode 100644 index 0000000..a4d0b29 --- /dev/null +++ b/call-generate-service.js @@ -0,0 +1,1550 @@ +// @ts-nocheck +import { oai_settings, chat_completion_sources, getChatCompletionModel, promptManager } from "../../../../openai.js"; +import { ChatCompletionService } from "../../../../custom-request.js"; +import { eventSource, event_types } from "../../../../../script.js"; +import { getContext } from "../../../../st-context.js"; +import { xbLog } from "../core/debug-core.js"; + +const SOURCE_TAG = 'xiaobaix-host'; + +const POSITIONS = Object.freeze({ BEFORE_PROMPT: 'BEFORE_PROMPT', IN_PROMPT: 'IN_PROMPT', IN_CHAT: 'IN_CHAT', AFTER_COMPONENT: 'AFTER_COMPONENT' }); +const KNOWN_KEYS = Object.freeze(new Set([ + 'main', 'chatHistory', 'worldInfo', 'worldInfoBefore', 'worldInfoAfter', + 'charDescription', 'charPersonality', 'scenario', 'personaDescription', + 'dialogueExamples', 'authorsNote', 'vectorsMemory', 'vectorsDataBank', + 'smartContext', 'jailbreak', 'nsfw', 'summary', 'bias', 'impersonate', 'quietPrompt', +])); +const resolveTargetOrigin = (origin) => { + if (typeof origin === 'string' && origin) return origin; + try { return window.location.origin; } catch { return '*'; } +}; + +// @ts-nocheck +class CallGenerateService { + constructor() { + /** @type {Map} */ + this.sessions = new Map(); + this._toggleBusy = false; + this._lastToggleSnapshot = null; + this._toggleQueue = Promise.resolve(); + } + + // ===== 通用错误处理 ===== + normalizeError(err, fallbackCode = 'API_ERROR', details = null) { + try { + if (!err) return { code: fallbackCode, message: 'Unknown error', details }; + if (typeof err === 'string') return { code: fallbackCode, message: err, details }; + const msg = err?.message || String(err); + // Map known cases + if (msg === 'INVALID_OPTIONS') return { code: 'INVALID_OPTIONS', message: 'Invalid options', details }; + if (msg === 'MISSING_MESSAGES') return { code: 'MISSING_MESSAGES', message: 'Missing messages', details }; + if (msg === 'INVALID_COMPONENT_REF') return { code: 'INVALID_COMPONENT_REF', message: 'Invalid component reference', details }; + if (msg === 'AMBIGUOUS_COMPONENT_NAME') return { code: 'AMBIGUOUS_COMPONENT_NAME', message: 'Ambiguous component name', details }; + if (msg === 'Unsupported provider') return { code: 'PROVIDER_UNSUPPORTED', message: msg, details }; + if (err?.name === 'AbortError') return { code: 'CANCELLED', message: 'Request cancelled', details }; + return { code: fallbackCode, message: msg, details }; + } catch { + return { code: fallbackCode, message: 'Error serialization failed', details }; + } + } + + sendError(sourceWindow, requestId, streamingEnabled, err, fallbackCode = 'API_ERROR', details = null, targetOrigin = null) { + const e = this.normalizeError(err, fallbackCode, details); + const type = streamingEnabled ? 'generateStreamError' : 'generateError'; + try { sourceWindow?.postMessage({ source: SOURCE_TAG, type, id: requestId, error: e }, resolveTargetOrigin(targetOrigin)); } catch {} + } + + /** + * @param {string|undefined} rawId + * @returns {string} + */ + normalizeSessionId(rawId) { + if (!rawId) return 'xb1'; + const m = String(rawId).match(/^xb(\d{1,2})$/i); + if (m) { + const n = Math.max(1, Math.min(10, Number(m[1]) || 1)); + return `xb${n}`; + } + const n = Math.max(1, Math.min(10, parseInt(String(rawId), 10) || 1)); + return `xb${n}`; + } + + /** + * @param {string} sessionId + */ + ensureSession(sessionId) { + const id = this.normalizeSessionId(sessionId); + if (!this.sessions.has(id)) { + this.sessions.set(id, { + id, + abortController: new AbortController(), + accumulated: '', + startedAt: Date.now(), + }); + } + return this.sessions.get(id); + } + + /** + * 选项校验(宽松)。 + * 支持仅 injections 或仅 userInput 构建场景。 + * @param {Object} options + * @throws {Error} INVALID_OPTIONS 当 options 非对象 + */ + validateOptions(options) { + if (!options || typeof options !== 'object') throw new Error('INVALID_OPTIONS'); + // 允许仅凭 injections 或 userInput 构建 + const hasComponents = options.components && Array.isArray(options.components.list); + const hasInjections = Array.isArray(options.injections) && options.injections.length > 0; + const hasUserInput = typeof options.userInput === 'string' && options.userInput.length >= 0; + if (!hasComponents && !hasInjections && !hasUserInput) { + // 仍允许空配置,但会构建空 + userInput + return; + } + } + + /** + * @param {string} provider + */ + mapProviderToSource(provider) { + const p = String(provider || '').toLowerCase(); + const map = { + openai: chat_completion_sources.OPENAI, + claude: chat_completion_sources.CLAUDE, + gemini: chat_completion_sources.MAKERSUITE, + google: chat_completion_sources.MAKERSUITE, + vertexai: chat_completion_sources.VERTEXAI, + cohere: chat_completion_sources.COHERE, + deepseek: chat_completion_sources.DEEPSEEK, + xai: chat_completion_sources.XAI, + groq: chat_completion_sources.GROQ, + openrouter: chat_completion_sources.OPENROUTER, + custom: chat_completion_sources.CUSTOM, + }; + return map[p] || null; + } + + /** + * 解析 API 与模型的继承/覆写,并注入代理/自定义地址 + * @param {any} api + */ + resolveApiConfig(api) { + const inherit = api?.inherit !== false; + let source = oai_settings?.chat_completion_source; + let model = getChatCompletionModel ? getChatCompletionModel() : undefined; + let overrides = api?.overrides || {}; + + if (!inherit) { + if (api?.provider) source = this.mapProviderToSource(api.provider); + if (api?.model) model = api.model; + } else { + if (overrides?.provider) source = this.mapProviderToSource(overrides.provider); + if (overrides?.model) model = overrides.model; + } + + if (!source) throw new Error(`Unsupported provider`); + if (!model) throw new Error('Model not specified'); + + const temperature = inherit ? Number(oai_settings?.temp_openai ?? '') : undefined; + const max_tokens = inherit ? (Number(oai_settings?.openai_max_tokens ?? 0) || 1024) : undefined; + const top_p = inherit ? Number(oai_settings?.top_p_openai ?? '') : undefined; + const frequency_penalty = inherit ? Number(oai_settings?.freq_pen_openai ?? '') : undefined; + const presence_penalty = inherit ? Number(oai_settings?.pres_pen_openai ?? '') : undefined; + + const resolved = { + chat_completion_source: source, + model, + temperature, + max_tokens, + top_p, + frequency_penalty, + presence_penalty, + // 代理/自定义地址占位 + reverse_proxy: undefined, + proxy_password: undefined, + custom_url: undefined, + custom_include_body: undefined, + custom_exclude_body: undefined, + custom_include_headers: undefined, + }; + + // 继承代理/自定义配置 + if (inherit) { + const proxySupported = new Set([ + chat_completion_sources.CLAUDE, + chat_completion_sources.OPENAI, + chat_completion_sources.MISTRALAI, + chat_completion_sources.MAKERSUITE, + chat_completion_sources.VERTEXAI, + chat_completion_sources.DEEPSEEK, + chat_completion_sources.XAI, + ]); + if (proxySupported.has(source) && oai_settings?.reverse_proxy) { + resolved.reverse_proxy = String(oai_settings.reverse_proxy).replace(/\/?$/, ''); + if (oai_settings?.proxy_password) resolved.proxy_password = String(oai_settings.proxy_password); + } + if (source === chat_completion_sources.CUSTOM) { + if (oai_settings?.custom_url) resolved.custom_url = String(oai_settings.custom_url); + if (oai_settings?.custom_include_body) resolved.custom_include_body = oai_settings.custom_include_body; + if (oai_settings?.custom_exclude_body) resolved.custom_exclude_body = oai_settings.custom_exclude_body; + if (oai_settings?.custom_include_headers) resolved.custom_include_headers = oai_settings.custom_include_headers; + } + } + + // 显式 baseURL 覆写 + const baseURL = overrides?.baseURL || api?.baseURL; + if (baseURL) { + if (resolved.chat_completion_source === chat_completion_sources.CUSTOM) { + resolved.custom_url = String(baseURL); + } else { + resolved.reverse_proxy = String(baseURL).replace(/\/?$/, ''); + } + } + + const ovw = inherit ? (api?.overrides || {}) : api || {}; + ['temperature', 'maxTokens', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'repetitionPenalty', 'stop', 'responseFormat', 'seed'] + .forEach((k) => { + const keyMap = { + maxTokens: 'max_tokens', + topP: 'top_p', + topK: 'top_k', + frequencyPenalty: 'frequency_penalty', + presencePenalty: 'presence_penalty', + repetitionPenalty: 'repetition_penalty', + responseFormat: 'response_format', + }; + const targetKey = keyMap[k] || k; + if (ovw[k] !== undefined) resolved[targetKey] = ovw[k]; + }); + + return resolved; + } + + /** + * @param {any[]} messages + * @param {any} apiCfg + * @param {boolean} stream + */ + buildChatPayload(messages, apiCfg, stream) { + const payload = { + stream: !!stream, + messages, + model: apiCfg.model, + chat_completion_source: apiCfg.chat_completion_source, + max_tokens: apiCfg.max_tokens, + temperature: apiCfg.temperature, + top_p: apiCfg.top_p, + top_k: apiCfg.top_k, + frequency_penalty: apiCfg.frequency_penalty, + presence_penalty: apiCfg.presence_penalty, + repetition_penalty: apiCfg.repetition_penalty, + stop: Array.isArray(apiCfg.stop) ? apiCfg.stop : undefined, + response_format: apiCfg.response_format, + seed: apiCfg.seed, + // 代理/自定义地址透传 + reverse_proxy: apiCfg.reverse_proxy, + proxy_password: apiCfg.proxy_password, + custom_url: apiCfg.custom_url, + custom_include_body: apiCfg.custom_include_body, + custom_exclude_body: apiCfg.custom_exclude_body, + custom_include_headers: apiCfg.custom_include_headers, + }; + return ChatCompletionService.createRequestData(payload); + } + + /** + * @param {Window} target + * @param {string} type + * @param {object} body + */ + postToTarget(target, type, body, targetOrigin = null) { + try { + target?.postMessage({ source: SOURCE_TAG, type, ...body }, resolveTargetOrigin(targetOrigin)); + } catch (e) {} + } + + // ===== ST Prompt 干跑捕获与组件切换 ===== + + _computeEnableIds(includeConfig) { + const ids = new Set(); + if (!includeConfig || typeof includeConfig !== 'object') return ids; + const c = includeConfig; + if (c.chatHistory?.enabled) ids.add('chatHistory'); + if (c.worldInfo?.enabled || c.worldInfo?.beforeHistory || c.worldInfo?.afterHistory) { + if (c.worldInfo?.beforeHistory !== false) ids.add('worldInfoBefore'); + if (c.worldInfo?.afterHistory !== false) ids.add('worldInfoAfter'); + } + if (c.character?.description) ids.add('charDescription'); + if (c.character?.personality) ids.add('charPersonality'); + if (c.character?.scenario) ids.add('scenario'); + if (c.persona?.description) ids.add('personaDescription'); + return ids; + } + + async _withTemporaryPromptToggles(includeConfig, fn) { return await this._withPromptToggle({ includeConfig }, fn); } + + async _capturePromptMessages({ includeConfig = null, quietText = '', skipWIAN = false }) { + const ctx = getContext(); + /** @type {any} */ + let capturedData = null; + const listener = (data) => { + if (data && typeof data === 'object' && Array.isArray(data.prompt)) { + capturedData = { ...data, prompt: data.prompt.slice() }; + } else if (Array.isArray(data)) { + capturedData = data.slice(); + } + }; + eventSource.on(event_types.GENERATE_AFTER_DATA, listener); + try { + const run = async () => { + await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); + }; + if (includeConfig) { + await this._withTemporaryPromptToggles(includeConfig, run); + } else { + await run(); + } + } finally { + eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); + } + if (!capturedData) return []; + if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); + if (Array.isArray(capturedData)) return capturedData.slice(); + return []; + } + + /** 使用 identifier 集合进行临时启停捕获 */ + async _withPromptEnabledSet(enableSet, fn) { return await this._withPromptToggle({ enableSet }, fn); } + + /** 统一启停切换:支持 includeConfig(标识集)或 enableSet(组件键集合) */ + async _withPromptToggle({ includeConfig = null, enableSet = null } = {}, fn) { + if (!promptManager || typeof promptManager.getPromptOrderForCharacter !== 'function') { + return await fn(); + } + // 使用队列保证串行执行,避免忙等 + const runExclusive = async () => { + this._toggleBusy = true; + let snapshot = []; + try { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + snapshot = order.map(e => ({ identifier: e.identifier, enabled: !!e.enabled })); + this._lastToggleSnapshot = snapshot.map(s => ({ ...s })); + + if (includeConfig) { + const enableIds = this._computeEnableIds(includeConfig); + order.forEach(e => { e.enabled = enableIds.has(e.identifier); }); + } else if (enableSet) { + const allow = enableSet instanceof Set ? enableSet : new Set(enableSet); + order.forEach(e => { + let ok = false; + for (const k of allow) { if (this._identifierMatchesKey(e.identifier, k)) { ok = true; break; } } + e.enabled = ok; + }); + } + + return await fn(); + } finally { + try { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + const mapSnap = new Map((this._lastToggleSnapshot || snapshot).map(s => [s.identifier, s.enabled])); + order.forEach(e => { if (mapSnap.has(e.identifier)) e.enabled = mapSnap.get(e.identifier); }); + } catch {} + this._toggleBusy = false; + this._lastToggleSnapshot = null; + } + }; + this._toggleQueue = this._toggleQueue.then(runExclusive, runExclusive); + return await this._toggleQueue; + } + + async _captureWithEnabledSet(enableSet, quietText = '', skipWIAN = false) { + const ctx = getContext(); + /** @type {any} */ + let capturedData = null; + const listener = (data) => { + if (data && typeof data === 'object' && Array.isArray(data.prompt)) { + capturedData = { ...data, prompt: data.prompt.slice() }; + } else if (Array.isArray(data)) { + capturedData = data.slice(); + } + }; + eventSource.on(event_types.GENERATE_AFTER_DATA, listener); + try { + await this._withPromptToggle({ enableSet }, async () => { + await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); + }); + } finally { + eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); + } + if (!capturedData) return []; + if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); + if (Array.isArray(capturedData)) return capturedData.slice(); + return []; + } + + // ===== 工具函数:组件与消息辅助 ===== + + /** + * 获取消息的 component key(用于匹配与排序)。 + * chatHistory-* 归并为 chatHistory;dialogueExamples x-y 归并为 dialogueExamples。 + * @param {string} identifier + * @returns {string} + */ + _getComponentKeyFromIdentifier(identifier) { + const id = String(identifier || ''); + if (id.startsWith('chatHistory')) return 'chatHistory'; + if (id.startsWith('dialogueExamples')) return 'dialogueExamples'; + return id; + } + + /** + * 判断具体 identifier 是否匹配某组件 key(处理聚合键)。 + * @param {string} identifier + * @param {string} key + * @returns {boolean} + */ + _identifierMatchesKey(identifier, key) { + const id = String(identifier || ''); + const k = String(key || ''); + if (!k || !id) return false; + if (k === 'dialogueExamples') return id.startsWith('dialogueExamples'); + if (k === 'worldInfo') return id === 'worldInfoBefore' || id === 'worldInfoAfter'; + if (k === 'chatHistory') return id === 'chatHistory' || id.startsWith('chatHistory'); + return id === k; + } + + /** 将组件键映射到创建锚点与角色,并生成稳定 identifier */ + _mapCreateAnchorForKey(key) { + const k = String(key || ''); + const sys = { position: POSITIONS.IN_PROMPT, role: 'system' }; + const asst = { position: POSITIONS.IN_PROMPT, role: 'assistant' }; + if (k === 'bias') return { ...asst, identifier: 'bias' }; + if (k === 'worldInfo' || k === 'worldInfoBefore') return { ...sys, identifier: 'worldInfoBefore' }; + if (k === 'worldInfoAfter') return { ...sys, identifier: 'worldInfoAfter' }; + if (k === 'charDescription') return { ...sys, identifier: 'charDescription' }; + if (k === 'charPersonality') return { ...sys, identifier: 'charPersonality' }; + if (k === 'scenario') return { ...sys, identifier: 'scenario' }; + if (k === 'personaDescription') return { ...sys, identifier: 'personaDescription' }; + if (k === 'quietPrompt') return { ...sys, identifier: 'quietPrompt' }; + if (k === 'impersonate') return { ...sys, identifier: 'impersonate' }; + if (k === 'authorsNote') return { ...sys, identifier: 'authorsNote' }; + if (k === 'vectorsMemory') return { ...sys, identifier: 'vectorsMemory' }; + if (k === 'vectorsDataBank') return { ...sys, identifier: 'vectorsDataBank' }; + if (k === 'smartContext') return { ...sys, identifier: 'smartContext' }; + if (k === 'summary') return { ...sys, identifier: 'summary' }; + if (k === 'dialogueExamples') return { ...sys, identifier: 'dialogueExamples 0-0' }; + // 默认走 system+IN_PROMPT,并使用 key 作为 identifier + return { ...sys, identifier: k }; + } + + /** + * 将 name 解析为唯一 identifier。 + * 规则: + * 1) 先快速命中已知原生键(直接返回同名 identifier) + * 2) 扫描 PromptManager 的“订单列表”和“集合”,按 name/label/title 精确匹配(大小写不敏感),唯一命中返回其 identifier + * 3) 失败时做一步 sanitize 对比(将非单词字符转为下划线) + * 4) 多命中抛出 AMBIGUOUS_COMPONENT_NAME,零命中返回 null + */ + _resolveNameToIdentifier(rawName) { + try { + const nm = String(rawName || '').trim(); + if (!nm) return null; + + // 1) 原生与常见聚合键的快速命中(支持用户用 name 指代这些键) + if (KNOWN_KEYS.has(nm)) return nm; + + const eq = (a, b) => String(a || '').trim() === String(b || '').trim(); + const sanitize = (s) => String(s || '').replace(/\W/g, '_'); + + const matches = new Set(); + + // 缓存命中 + try { + const nameCache = this._getNameCache(); + if (nameCache.has(nm)) return nameCache.get(nm); + } catch {} + + // 2) 扫描 PromptManager 的订单(显示用) + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm.getPromptOrderForCharacter(activeChar) || []; + for (const e of order) { + const id = e?.identifier; + if (!id) continue; + const candidates = [e?.name, e?.label, e?.title, id].filter(Boolean); + if (candidates.some(x => eq(x, nm))) { + matches.add(id); + continue; + } + } + } + } catch {} + + // 3) 扫描 Prompt 集合(运行期合并后的集合) + try { + if (promptManager && typeof promptManager.getPromptCollection === 'function') { + const pc = promptManager.getPromptCollection(); + const coll = pc?.collection || []; + for (const p of coll) { + const id = p?.identifier; + if (!id) continue; + const candidates = [p?.name, p?.label, p?.title, id].filter(Boolean); + if (candidates.some(x => eq(x, nm))) { + matches.add(id); + continue; + } + } + } + } catch {} + + // 4) 失败时尝试 sanitize 名称与 identifier 的弱匹配 + if (matches.size === 0) { + const nmSan = sanitize(nm); + try { + if (promptManager && typeof promptManager.getPromptCollection === 'function') { + const pc = promptManager.getPromptCollection(); + const coll = pc?.collection || []; + for (const p of coll) { + const id = p?.identifier; + if (!id) continue; + if (sanitize(id) === nmSan) { + matches.add(id); + } + } + } + } catch {} + } + + if (matches.size === 1) { + const id = Array.from(matches)[0]; + try { this._getNameCache().set(nm, id); } catch {} + return id; + } + if (matches.size > 1) { + const err = new Error('AMBIGUOUS_COMPONENT_NAME'); + throw err; + } + return null; + } catch (e) { + // 透传歧义错误,其它情况视为未命中 + if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; + return null; + } + } + + /** + * 解析组件引用 token: + * - 'ALL' → 特殊标记 + * - 'id:identifier' → 直接返回 identifier + * - 'name:xxx' → 通过名称解析为 identifier(大小写敏感) + * - 'xxx' → 先按 name 精确匹配,未命中回退为 identifier + * @param {string} token + * @returns {string|null} + */ + _parseComponentRefToken(token) { + if (!token) return null; + if (typeof token !== 'string') return null; + const raw = token.trim(); + if (!raw) return null; + if (raw.toLowerCase() === 'all') return 'ALL'; + // 特殊模式:仅启用预设中已开启的组件 + if (raw.toLowerCase() === 'all_preon') return 'ALL_PREON'; + if (raw.startsWith('id:')) return raw.slice(3).trim(); + if (raw.startsWith('name:')) { + const nm = raw.slice(5).trim(); + const id = this._resolveNameToIdentifier(nm); + if (id) return id; + const err = new Error('INVALID_COMPONENT_REF'); + throw err; + } + // 默认按 name 精确匹配;未命中则回退当作 identifier 使用 + try { + const byName = this._resolveNameToIdentifier(raw); + if (byName) return byName; + } catch (e) { + if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; + } + return raw; + } + + // ===== 轻量缓存:按 activeCharacter 维度缓存 name→identifier 与 footprint ===== + _getActiveCharacterIdSafe() { + try { + return promptManager?.activeCharacter ?? 'default'; + } catch { return 'default'; } + } + + _getNameCache() { + if (!this._nameCache) this._nameCache = new Map(); + const key = this._getActiveCharacterIdSafe(); + if (!this._nameCache.has(key)) this._nameCache.set(key, new Map()); + return this._nameCache.get(key); + } + + _getFootprintCache() { + if (!this._footprintCache) this._footprintCache = new Map(); + const key = this._getActiveCharacterIdSafe(); + if (!this._footprintCache.has(key)) this._footprintCache.set(key, new Map()); + return this._footprintCache.get(key); + } + + /** + * 解析统一 list:返回三元组 + * - references: 组件引用序列 + * - inlineInjections: 内联注入项(含原始索引) + * - listOverrides: 行内覆写(以组件引用为键) + * @param {Array} list + * @returns {{references:string[], inlineInjections:Array<{index:number,item:any}>, listOverrides:Object}} + */ + _parseUnifiedList(list) { + const references = []; + const inlineInjections = []; + const listOverrides = {}; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (typeof item === 'string') { + references.push(item); + continue; + } + if (item && typeof item === 'object' && item.role && item.content) { + inlineInjections.push({ index: i, item }); + continue; + } + if (item && typeof item === 'object') { + const keys = Object.keys(item); + for (const k of keys) { + // k 是组件引用,如 'id:charDescription' / 'scenario' / 'chatHistory' / 'main' + references.push(k); + const cfg = item[k]; + if (cfg && typeof cfg === 'object') { + listOverrides[k] = Object.assign({}, listOverrides[k] || {}, cfg); + } + } + } + } + return { references, inlineInjections, listOverrides }; + } + + /** + * 基于原始 list 计算内联注入的邻接规则,映射到 position/depth。 + * 默认:紧跟前一组件(AFTER_COMPONENT);首项+attach=prev → BEFORE_PROMPT;邻接 chatHistory → IN_CHAT。 + * @param {Array} rawList + * @param {Array<{index:number,item:any}>} inlineInjections + * @returns {Array<{role:string,content:string,position:string,depth?:number,_afterRef?:string}>} + */ + _mapInlineInjectionsUnified(rawList, inlineInjections) { + const result = []; + const getRefAt = (idx, dir) => { + let j = idx + (dir < 0 ? -1 : 1); + while (j >= 0 && j < rawList.length) { + const it = rawList[j]; + if (typeof it === 'string') { + const token = this._parseComponentRefToken(it); + if (token && token !== 'ALL') return token; + } else if (it && typeof it === 'object') { + if (it.role && it.content) { + // inline injection, skip + } else { + const ks = Object.keys(it); + if (ks.length) { + const tk = this._parseComponentRefToken(ks[0]); + if (tk) return tk; + } + } + } + j += (dir < 0 ? -1 : 1); + } + return null; + }; + for (const { index, item } of inlineInjections) { + const prevRef = getRefAt(index, -1); + const nextRef = getRefAt(index, +1); + const attach = item.attach === 'prev' || item.attach === 'next' ? item.attach : 'auto'; + // 显式 position 优先 + if (item.position && typeof item.position === 'string') { + result.push({ role: item.role, content: item.content, position: item.position, depth: item.depth || 0 }); + continue; + } + // 有前邻组件 → 默认插到该组件之后(满足示例:位于 charDescription 之后、main 之前) + if (prevRef) { + result.push({ role: item.role, content: item.content, position: POSITIONS.AFTER_COMPONENT, _afterRef: prevRef }); + continue; + } + if (index === 0 && attach === 'prev') { + result.push({ role: item.role, content: item.content, position: POSITIONS.BEFORE_PROMPT }); + continue; + } + if (prevRef === 'chatHistory' || nextRef === 'chatHistory') { + result.push({ role: item.role, content: item.content, position: POSITIONS.IN_CHAT, depth: 0, _attach: attach === 'prev' ? 'before' : 'after' }); + continue; + } + result.push({ role: item.role, content: item.content, position: POSITIONS.IN_PROMPT }); + } + return result; + } + + /** + * 根据组件集合过滤消息(当 list 不含 ALL)。 + * @param {Array} messages + * @param {Set} wantedKeys + * @returns {Array} + */ + _filterMessagesByComponents(messages, wantedKeys) { + if (!wantedKeys || !wantedKeys.size) return []; + return messages.filter(m => wantedKeys.has(this._getComponentKeyFromIdentifier(m?.identifier))); + } + + /** 稳定重排:对目标子集按给定顺序排序,其他保持相对不变 */ + _stableReorderSubset(messages, orderedKeys) { + if (!Array.isArray(messages) || !orderedKeys || !orderedKeys.length) return messages; + const orderIndex = new Map(); + orderedKeys.forEach((k, i) => orderIndex.set(k, i)); + // 提取目标子集的元素与其原索引 + const targetIndices = []; + const targetMessages = []; + messages.forEach((m, idx) => { + const key = this._getComponentKeyFromIdentifier(m?.identifier); + if (orderIndex.has(key)) { + targetIndices.push(idx); + targetMessages.push({ m, ord: orderIndex.get(key) }); + } + }); + if (!targetIndices.length) return messages; + // 对目标子集按 ord 稳定排序 + targetMessages.sort((a, b) => a.ord - b.ord); + // 将排序后的目标消息放回原有“子集槽位”,非目标元素完全不动 + const out = messages.slice(); + for (let i = 0; i < targetIndices.length; i++) { + out[targetIndices[i]] = targetMessages[i].m; + } + return out; + } + + // ===== 缺失 identifier 的兜底标注 ===== + _normalizeText(s) { + return String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); + } + + _stripNamePrefix(s) { + return String(s || '').replace(/^\s*[^:]{1,32}:\s*/, ''); + } + + _normStrip(s) { return this._normalizeText(this._stripNamePrefix(s)); } + + _createIsFromChat() { + try { + const ctx = getContext(); + const chatArr = Array.isArray(ctx?.chat) ? ctx.chat : []; + const chatNorms = chatArr.map(m => this._normStrip(m?.mes)).filter(Boolean); + const chatSet = new Set(chatNorms); + return (content) => { + const n = this._normStrip(content); + if (!n) return false; + if (chatSet.has(n)) return true; + for (const c of chatNorms) { + const a = n.length, b = c.length; + const minL = Math.min(a, b), maxL = Math.max(a, b); + if (minL < 20) continue; + if (((a >= b && n.includes(c)) || (b >= a && c.includes(n))) && minL / maxL >= 0.8) return true; + } + return false; + }; + } catch { + return () => false; + } + } + + async _annotateIdentifiersIfMissing(messages, targetKeys) { + const arr = Array.isArray(messages) ? messages.map(m => ({ ...m })) : []; + if (!arr.length) return arr; + // 标注 chatHistory:依据 role + 来源判断 + const isFromChat = this._createIsFromChat(); + for (const m of arr) { + if (!m?.identifier && (m?.role === 'user' || m?.role === 'assistant') && isFromChat(m.content)) { + m.identifier = 'chatHistory-annotated'; + } + } + // 即使部分已有 identifier,也继续尝试为缺失者做 footprint 标注 + // 若仍缺失,按目标 keys 单独捕获来反向标注 + const keys = Array.from(new Set((Array.isArray(targetKeys) ? targetKeys : []).filter(Boolean))); + if (!keys.length) return arr; + const footprint = new Map(); // key -> Set of norm strings + for (const key of keys) { + try { + if (key === 'chatHistory') continue; // 已在上面标注 + // footprint 缓存命中 + const fpCache = this._getFootprintCache(); + if (fpCache.has(key)) { + footprint.set(key, fpCache.get(key)); + } else { + const capture = await this._captureWithEnabledSet(new Set([key]), '', false); + const normSet = new Set(capture.map(x => `[${x.role}] ${this._normStrip(x.content)}`)); + footprint.set(key, normSet); + try { fpCache.set(key, normSet); } catch {} + } + } catch {} + } + for (const m of arr) { + if (m?.identifier) continue; + const sig = `[${m?.role}] ${this._normStrip(m?.content)}`; + for (const [key, set] of footprint.entries()) { + if (set.has(sig)) { m.identifier = key; break; } + } + } + return arr; + } + + /** 覆写:通用组件 disable/replace(文本级),不影响采样参数 */ + _applyGeneralOverrides(messages, overridesByComponent) { + if (!overridesByComponent) return messages; + let out = messages.slice(); + for (const ref in overridesByComponent) { + if (!Object.prototype.hasOwnProperty.call(overridesByComponent, ref)) continue; + const cfg = overridesByComponent[ref]; + if (!cfg || typeof cfg !== 'object') continue; + const key = this._parseComponentRefToken(ref); + if (!key) continue; + if (key === 'chatHistory') continue; // 历史专属逻辑另行处理 + const disable = !!cfg.disable; + const replace = typeof cfg.replace === 'string' ? cfg.replace : null; + if (disable) { + out = out.filter(m => this._getComponentKeyFromIdentifier(m?.identifier) !== key); + continue; + } + if (replace != null) { + out = out.map(m => this._getComponentKeyFromIdentifier(m?.identifier) === key ? { ...m, content: replace } : m); + } + } + return out; + } + + /** 仅对 chatHistory 应用 selector/replaceAll/replace */ + _applyChatHistoryOverride(messages, historyCfg) { + if (!historyCfg) return messages; + const all = messages.slice(); + const indexes = []; + for (let i = 0; i < all.length; i++) { + const m = all[i]; + if (this._getComponentKeyFromIdentifier(m?.identifier) === 'chatHistory') indexes.push(i); + } + if (indexes.length === 0) return messages; + if (historyCfg.disable) { + // 直接移除全部历史 + return all.filter((m, idx) => !indexes.includes(idx)); + } + const history = indexes.map(i => all[i]); + + // selector 过滤 + let selected = history.slice(); + if (historyCfg.selector) { + // 在历史子集上应用 selector + selected = this.applyChatHistorySelector(history, historyCfg.selector); + } + + // 替换逻辑 + let replaced = selected.slice(); + if (historyCfg.replaceAll && Array.isArray(historyCfg.with)) { + replaced = (historyCfg.with || []).map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replaceAll-${idx}` })); + } + if (Array.isArray(historyCfg.replace)) { + // 在 replaced 上按顺序执行多段替换 + for (const step of historyCfg.replace) { + const withArr = Array.isArray(step?.with) ? step.with : []; + const newMsgs = withArr.map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replace-${Date.now()}-${idx}` })); + let indices = []; + if (step?.indices?.values && Array.isArray(step.indices.values) && step.indices.values.length) { + const n = replaced.length; + indices = step.indices.values.map(v0 => { + let v = Number(v0); + if (Number.isNaN(v)) return -1; + if (v < 0) v = n + v; + return (v >= 0 && v < n) ? v : -1; + }).filter(v => v >= 0); + } else if (step?.range && (step.range.start !== undefined || step.range.end !== undefined)) { + let { start = 0, end = replaced.length - 1 } = step.range; + const n = replaced.length; + start = Number(start); end = Number(end); + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = n - 1; + if (start < 0) start = n + start; + if (end < 0) end = n + end; + start = Math.max(0, start); end = Math.min(n - 1, end); + if (start <= end) indices = Array.from({ length: end - start + 1 }, (_, k) => start + k); + } else if (step?.last != null) { + const k = Math.max(0, Number(step.last) || 0); + const n = replaced.length; + indices = k > 0 ? Array.from({ length: Math.min(k, n) }, (_, j) => n - k + j) : []; + } + if (indices.length) { + // 按出现顺序处理:先删除这些索引,再按同位置插入(采用最小索引处插入) + const set = new Set(indices); + const kept = replaced.filter((_, idx) => !set.has(idx)); + const insertAt = Math.min(...indices); + replaced = kept.slice(0, insertAt).concat(newMsgs).concat(kept.slice(insertAt)); + } + } + } + + // 将 replaced 合并回全量:找到历史的第一个索引,替换整个历史窗口 + const first = Math.min(...indexes); + const last = Math.max(...indexes); + const before = all.slice(0, first); + const after = all.slice(last + 1); + return before.concat(replaced).concat(after); + } + + /** 将高级 injections 应用到 messages */ + _applyAdvancedInjections(messages, injections = []) { + if (!Array.isArray(injections) || injections.length === 0) return messages; + const out = messages.slice(); + // 计算 chatHistory 边界 + const historyIdx = []; + for (let i = 0; i < out.length; i++) if (this._getComponentKeyFromIdentifier(out[i]?.identifier) === 'chatHistory') historyIdx.push(i); + const hasHistory = historyIdx.length > 0; + const historyStart = hasHistory ? Math.min(...historyIdx) : -1; + const historyEnd = hasHistory ? Math.max(...historyIdx) : -1; + for (const inj of injections) { + const role = inj?.role; const content = inj?.content; + if (!role || typeof content !== 'string') continue; + const forcedId = inj && typeof inj.identifier === 'string' && inj.identifier.trim() ? String(inj.identifier).trim() : null; + const msg = { role, content, identifier: forcedId || `injection-${inj.position || POSITIONS.IN_PROMPT}-${Date.now()}-${Math.random().toString(36).slice(2)}` }; + if (inj.position === POSITIONS.BEFORE_PROMPT) { + out.splice(0, 0, msg); + continue; + } + if (inj.position === POSITIONS.AFTER_COMPONENT) { + const ref = inj._afterRef || null; + let inserted = false; + if (ref) { + for (let i = out.length - 1; i >= 0; i--) { + const id = out[i]?.identifier; + if (this._identifierMatchesKey(id, ref) || this._getComponentKeyFromIdentifier(id) === ref) { + out.splice(i + 1, 0, msg); + inserted = true; break; + } + } + } + if (!inserted) { + // 回退同 IN_PROMPT + if (hasHistory) { + const depth = Math.max(0, Number(inj.depth) || 0); + const insertPos = Math.max(0, historyStart - depth); + out.splice(insertPos, 0, msg); + } else { + out.splice(0, 0, msg); + } + } + continue; + } + if (inj.position === POSITIONS.IN_CHAT && hasHistory) { + // depth=0 → 历史末尾后;depth>0 → 进入历史内部; + const depth = Math.max(0, Number(inj.depth) || 0); + if (inj._attach === 'before') { + const insertPos = Math.max(historyStart - depth, 0); + out.splice(insertPos, 0, msg); + } else { + const insertPos = Math.min(out.length, historyEnd + 1 - depth); + out.splice(Math.max(historyStart, insertPos), 0, msg); + } + continue; + } + // IN_PROMPT 或无历史:在 chatHistory 之前插入,否则置顶后 + if (hasHistory) { + const depth = Math.max(0, Number(inj.depth) || 0); + const insertPos = Math.max(0, historyStart - depth); + out.splice(insertPos, 0, msg); + } else { + out.splice(0, 0, msg); + } + } + return out; + } + + _mergeMessages(baseMessages, extraMessages) { + const out = []; + const seen = new Set(); + const norm = (s) => String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); + const push = (m) => { + if (!m || !m.content) return; + const key = `${m.role}:${norm(m.content)}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ role: m.role, content: m.content }); + }; + baseMessages.forEach(push); + (extraMessages || []).forEach(push); + return out; + } + + _splitMessagesForHistoryOps(messages) { + // history: user/assistant; systemOther: 其余 + const history = []; + const systemOther = []; + for (const m of messages) { + if (!m || typeof m.content !== 'string') continue; + if (m.role === 'user' || m.role === 'assistant') history.push(m); + else systemOther.push(m); + } + return { history, systemOther }; + } + + _applyRolesFilter(list, rolesCfg) { + if (!rolesCfg || (!rolesCfg.include && !rolesCfg.exclude)) return list; + const inc = Array.isArray(rolesCfg.include) && rolesCfg.include.length ? new Set(rolesCfg.include) : null; + const exc = Array.isArray(rolesCfg.exclude) && rolesCfg.exclude.length ? new Set(rolesCfg.exclude) : null; + return list.filter(m => { + const r = m.role; + if (inc && !inc.has(r)) return false; + if (exc && exc.has(r)) return false; + return true; + }); + } + + _applyContentFilter(list, filterCfg) { + if (!filterCfg) return list; + const { contains, regex, fromUserNames } = filterCfg; + let out = list.slice(); + if (contains) { + const needles = Array.isArray(contains) ? contains : [contains]; + out = out.filter(m => needles.some(k => String(m.content).includes(String(k)))); + } + if (regex) { + try { + const re = new RegExp(regex); + out = out.filter(m => re.test(String(m.content))); + } catch {} + } + if (fromUserNames && fromUserNames.length) { + // 仅当 messages 中附带 name 时生效;否则忽略 + out = out.filter(m => !m.name || fromUserNames.includes(m.name)); + } + // 时间戳过滤需要原始数据支持,这里忽略(占位) + return out; + } + + _applyAnchorWindow(list, anchorCfg) { + if (!anchorCfg || !list.length) return list; + const { anchor = 'lastUser', before = 0, after = 0 } = anchorCfg; + // 找到锚点索引 + let idx = -1; + if (anchor === 'lastUser') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'user') { idx = i; break; } + } else if (anchor === 'lastAssistant') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'assistant') { idx = i; break; } + } else if (anchor === 'lastSystem') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'system') { idx = i; break; } + } + if (idx === -1) return list; + const start = Math.max(0, idx - Number(before || 0)); + const end = Math.min(list.length - 1, idx + Number(after || 0)); + return list.slice(start, end + 1); + } + + _applyIndicesRange(list, selector) { + let result = list.slice(); + // indices 优先 + if (Array.isArray(selector?.indices?.values) && selector.indices.values.length) { + const vals = selector.indices.values; + const picked = []; + const n = list.length; + for (const v0 of vals) { + let v = Number(v0); + if (Number.isNaN(v)) continue; + if (v < 0) v = n + v; // 负索引 + if (v >= 0 && v < n) picked.push(list[v]); + } + result = picked; + return result; + } + if (selector?.range && (selector.range.start !== undefined || selector.range.end !== undefined)) { + let { start = 0, end = list.length - 1 } = selector.range; + const n = list.length; + start = Number(start); end = Number(end); + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = n - 1; + if (start < 0) start = n + start; + if (end < 0) end = n + end; + start = Math.max(0, start); end = Math.min(n - 1, end); + if (start > end) return []; + return list.slice(start, end + 1); + } + if (selector?.last !== undefined && selector.last !== null) { + const k = Math.max(0, Number(selector.last) || 0); + if (k === 0) return []; + const n = list.length; + return list.slice(Math.max(0, n - k)); + } + return result; + } + + _applyTakeEvery(list, step) { + const s = Math.max(1, Number(step) || 1); + if (s === 1) return list; + const out = []; + for (let i = 0; i < list.length; i += s) out.push(list[i]); + return out; + } + + _applyLimit(list, limitCfg) { + if (!limitCfg) return list; + // 仅实现 count,tokenBudget 预留 + const count = Number(limitCfg.count || 0); + if (count > 0 && list.length > count) { + const how = limitCfg.truncateStrategy || 'last'; + if (how === 'first') return list.slice(0, count); + if (how === 'middle') { + const left = Math.floor(count / 2); + const right = count - left; + return list.slice(0, left).concat(list.slice(-right)); + } + if (how === 'even') { + const step = Math.ceil(list.length / count); + const out = []; + for (let i = 0; i < list.length && out.length < count; i += step) out.push(list[i]); + return out; + } + // default: 'last' → 取末尾 + return list.slice(-count); + } + return list; + } + + applyChatHistorySelector(messages, selector) { + if (!selector || !Array.isArray(messages) || !messages.length) return messages; + const { history, systemOther } = this._splitMessagesForHistoryOps(messages); + let list = history; + // roles/filter/anchor → indices/range/last → takeEvery → limit + list = this._applyRolesFilter(list, selector.roles); + list = this._applyContentFilter(list, selector.filter); + list = this._applyAnchorWindow(list, selector.anchorWindow); + list = this._applyIndicesRange(list, selector); + list = this._applyTakeEvery(list, selector.takeEvery); + list = this._applyLimit(list, selector.limit || (selector.last ? { count: Number(selector.last) } : null)); + // 合并非历史部分 + return systemOther.concat(list); + } + + // ===== 发送实现(构建后的统一发送) ===== + + async _sendMessages(messages, options, requestId, sourceWindow, targetOrigin = null) { + const sessionId = this.normalizeSessionId(options?.session?.id || 'xb1'); + const session = this.ensureSession(sessionId); + const streamingEnabled = options?.streaming?.enabled !== false; // 默认开 + const apiCfg = this.resolveApiConfig(options?.api || {}); + const payload = this.buildChatPayload(messages, apiCfg, streamingEnabled); + + try { + const shouldExport = !!(options?.debug?.enabled || options?.debug?.exportPrompt); + const already = options?.debug?._exported === true; + if (shouldExport && !already) { + this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: (messages || []).map(m => ({ role: m.role, content: m.content })) }, targetOrigin); + } + + if (streamingEnabled) { + this.postToTarget(sourceWindow, 'generateStreamStart', { id: requestId, sessionId }, targetOrigin); + const streamFn = await ChatCompletionService.sendRequest(payload, false, session.abortController.signal); + let last = ''; + const generator = typeof streamFn === 'function' ? streamFn() : null; + for await (const { text } of (generator || [])) { + const chunk = text.slice(last.length); + last = text; + session.accumulated = text; + this.postToTarget(sourceWindow, 'generateStreamChunk', { id: requestId, chunk, accumulated: text, metadata: {} }, targetOrigin); + } + const result = { + success: true, + result: session.accumulated, + sessionId, + metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, + }; + this.postToTarget(sourceWindow, 'generateStreamComplete', { id: requestId, result }, targetOrigin); + return result; + } else { + const extracted = await ChatCompletionService.sendRequest(payload, true, session.abortController.signal); + const result = { + success: true, + result: String((extracted && extracted.content) || ''), + sessionId, + metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, + }; + this.postToTarget(sourceWindow, 'generateResult', { id: requestId, result }, targetOrigin); + return result; + } + } catch (err) { + this.sendError(sourceWindow, requestId, streamingEnabled, err, 'API_ERROR', null, targetOrigin); + return null; + } + } + + // ===== 主流程 ===== + async handleRequestInternal(options, requestId, sourceWindow, targetOrigin = null) { + // 1) 校验 + this.validateOptions(options); + + // 2) 解析组件列表与内联注入 + const list = Array.isArray(options?.components?.list) ? options.components.list.slice() : undefined; + let baseStrategy = 'EMPTY'; // EMPTY | ALL | ALL_PREON | SUBSET + let orderedRefs = []; + let inlineMapped = []; + let listLevelOverrides = {}; + const unorderedKeys = new Set(); + if (list && list.length) { + const { references, inlineInjections, listOverrides } = this._parseUnifiedList(list); + listLevelOverrides = listOverrides || {}; + const parsedRefs = references.map(t => this._parseComponentRefToken(t)); + const containsAll = parsedRefs.includes('ALL'); + const containsAllPreOn = parsedRefs.includes('ALL_PREON'); + if (containsAll) { + baseStrategy = 'ALL'; + // ALL 仅作为开关标识,子集重排目标为去除 ALL 后的引用列表 + orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); + } else if (containsAllPreOn) { + baseStrategy = 'ALL_PREON'; + // ALL_PREON:仅启用“预设里已开启”的组件,子集重排目标为去除该标记后的引用列表 + orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); + } else { + baseStrategy = 'SUBSET'; + orderedRefs = parsedRefs.filter(Boolean); + } + inlineMapped = this._mapInlineInjectionsUnified(list, inlineInjections); + // 放宽:ALL 可出现在任意位置,作为“启用全部”的标志 + + // 解析 order=false:不参与重排 + for (const rawKey in listLevelOverrides) { + if (!Object.prototype.hasOwnProperty.call(listLevelOverrides, rawKey)) continue; + const k = this._parseComponentRefToken(rawKey); + if (!k) continue; + if (listLevelOverrides[rawKey] && listLevelOverrides[rawKey].order === false) unorderedKeys.add(k); + } + } + + // 3) 干跑捕获(基座) + let captured = []; + if (baseStrategy === 'EMPTY') { + captured = []; + } else { + // 不将 userInput 作为 quietText 干跑,以免把其注入到历史里 + if (baseStrategy === 'ALL') { + // 路径B:ALL 时先全开启用集合再干跑,保证真实组件尽量出现 + // 读取 promptManager 订单并构造 allow 集合 + let allow = new Set(); + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + allow = new Set(order.map(e => e.identifier)); + } + } catch {} + const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + captured = await this._withPromptEnabledSet(allow, run); + } else if (baseStrategy === 'ALL_PREON') { + // 仅启用预设里已开启的组件 + let allow = new Set(); + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + allow = new Set(order.filter(e => !!e?.enabled).map(e => e.identifier)); + } + } catch {} + const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + captured = await this._withPromptEnabledSet(allow, run); + } else { + captured = await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + } + } + + // 4) 依据策略计算启用集合与顺序 + const annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []); + let working = await this._annotateIdentifiersIfMissing(captured.slice(), annotateKeys); + working = this._applyOrderingStrategy(working, baseStrategy, orderedRefs, unorderedKeys); + + // 5) 覆写与创建 + working = this._applyInlineOverrides(working, listLevelOverrides); + + // 6) 注入(内联 + 高级) + working = this._applyAllInjections(working, inlineMapped, options?.injections); + + // 7) 用户输入追加 + working = this._appendUserInput(working, options?.userInput); + + // 8) 调试导出 + this._exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug: options?.debug, targetOrigin }); + + // 9) 发送 + return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow, targetOrigin); + } + + _applyOrderingStrategy(messages, baseStrategy, orderedRefs, unorderedKeys) { + let out = messages.slice(); + if (baseStrategy === 'SUBSET') { + const want = new Set(orderedRefs); + out = this._filterMessagesByComponents(out, want); + } else if ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') && orderedRefs.length) { + const targets = orderedRefs.filter(k => !unorderedKeys.has(k)); + if (targets.length) out = this._stableReorderSubset(out, targets); + } + return out; + } + + _applyInlineOverrides(messages, byComp) { + let out = messages.slice(); + if (!byComp) return out; + out = this._applyGeneralOverrides(out, byComp); + const ensureInjections = []; + for (const ref in byComp) { + if (!Object.prototype.hasOwnProperty.call(byComp, ref)) continue; + const key = this._parseComponentRefToken(ref); + if (!key || key === 'chatHistory') continue; + const cfg = byComp[ref]; + if (!cfg || typeof cfg.replace !== 'string') continue; + const exists = out.some(m => this._identifierMatchesKey(m?.identifier, key) || this._getComponentKeyFromIdentifier(m?.identifier) === key); + if (exists) continue; + const map = this._mapCreateAnchorForKey(key); + ensureInjections.push({ position: map.position, role: map.role, content: cfg.replace, identifier: map.identifier }); + } + if (ensureInjections.length) { + out = this._applyAdvancedInjections(out, ensureInjections); + } + if (byComp['id:chatHistory'] || byComp['chatHistory']) { + const cfg = byComp['id:chatHistory'] || byComp['chatHistory']; + out = this._applyChatHistoryOverride(out, cfg); + } + return out; + } + + _applyAllInjections(messages, inlineMapped, advancedInjections) { + let out = messages.slice(); + if (inlineMapped && inlineMapped.length) { + out = this._applyAdvancedInjections(out, inlineMapped); + } + if (Array.isArray(advancedInjections) && advancedInjections.length) { + out = this._applyAdvancedInjections(out, advancedInjections); + } + return out; + } + + _appendUserInput(messages, userInput) { + const out = messages.slice(); + if (typeof userInput === 'string' && userInput.length >= 0) { + out.push({ role: 'user', content: String(userInput || ''), identifier: 'userInput' }); + } + return out; + } + + _exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug, targetOrigin }) { + const exportPrompt = !!(debug?.enabled || debug?.exportPrompt); + if (exportPrompt) this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: working.map(m => ({ role: m.role, content: m.content })) }, targetOrigin); + if (debug?.exportBlueprint) { + try { + const bp = { + id: requestId, + components: { strategy: baseStrategy, order: orderedRefs }, + injections: (debug?.injections || []).concat(inlineMapped || []), + overrides: listLevelOverrides || null, + }; + this.postToTarget(sourceWindow, 'blueprint', bp, targetOrigin); + } catch {} + } + } + + /** + * 入口:处理 generateRequest(统一入口) + */ + async handleGenerateRequest(options, requestId, sourceWindow, targetOrigin = null) { + let streamingEnabled = false; + try { + streamingEnabled = options?.streaming?.enabled !== false; + try { + if (xbLog.isEnabled?.()) { + const comps = options?.components?.list; + const compsCount = Array.isArray(comps) ? comps.length : 0; + const userInputLen = String(options?.userInput || '').length; + xbLog.info('callGenerateBridge', `generateRequest id=${requestId} stream=${!!streamingEnabled} comps=${compsCount} userInputLen=${userInputLen}`); + } + } catch {} + return await this.handleRequestInternal(options, requestId, sourceWindow, targetOrigin); + } catch (err) { + try { xbLog.error('callGenerateBridge', `generateRequest failed id=${requestId}`, err); } catch {} + this.sendError(sourceWindow, requestId, streamingEnabled, err, 'BAD_REQUEST', null, targetOrigin); + return null; + } + } + + /** 取消会话 */ + cancel(sessionId) { + const s = this.sessions.get(this.normalizeSessionId(sessionId)); + try { s?.abortController?.abort(); } catch {} + } + + /** 清理所有会话 */ + cleanup() { + this.sessions.forEach(s => { try { s.abortController?.abort(); } catch {} }); + this.sessions.clear(); + } +} + +const callGenerateService = new CallGenerateService(); + +export async function handleGenerateRequest(options, requestId, sourceWindow, targetOrigin = null) { + return await callGenerateService.handleGenerateRequest(options, requestId, sourceWindow, targetOrigin); +} + +// Host bridge for handling iframe generateRequest → respond via postMessage +let __xb_generate_listener_attached = false; +let __xb_generate_listener = null; + +export function initCallGenerateHostBridge() { + if (typeof window === 'undefined') return; + if (__xb_generate_listener_attached) return; + try { xbLog.info('callGenerateBridge', 'initCallGenerateHostBridge'); } catch {} + __xb_generate_listener = async function (event) { + try { + const data = event && event.data || {}; + if (!data || data.type !== 'generateRequest') return; + const id = data.id; + const options = data.options || {}; + await handleGenerateRequest(options, id, event.source || window, event.origin); + } catch (e) { + try { xbLog.error('callGenerateBridge', 'generateRequest listener error', e); } catch {} + } + }; + // eslint-disable-next-line no-restricted-syntax -- bridge listener; origin can be null for sandboxed iframes. + try { window.addEventListener('message', __xb_generate_listener); } catch (e) {} + __xb_generate_listener_attached = true; +} + +export function cleanupCallGenerateHostBridge() { + if (typeof window === 'undefined') return; + if (!__xb_generate_listener_attached) return; + try { xbLog.info('callGenerateBridge', 'cleanupCallGenerateHostBridge'); } catch {} + try { window.removeEventListener('message', __xb_generate_listener); } catch (e) {} + __xb_generate_listener_attached = false; + __xb_generate_listener = null; + try { callGenerateService.cleanup(); } catch (e) {} +} + +if (typeof window !== 'undefined') { + Object.assign(window, { xiaobaixCallGenerateService: callGenerateService, initCallGenerateHostBridge, cleanupCallGenerateHostBridge }); + try { initCallGenerateHostBridge(); } catch (e) {} + try { + window.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); + } catch (_) {} + }); + document.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); + } catch (_) {} + }); + window.addEventListener('beforeunload', () => { try { cleanupCallGenerateHostBridge(); } catch (_) {} }); + } catch (_) {} + + // ===== 全局 API 暴露:与 iframe 调用方式完全一致 ===== + // 创建命名空间 + window.LittleWhiteBox = window.LittleWhiteBox || {}; + + /** + * 全局 callGenerate 函数 + * 使用方式与 iframe 中完全一致:await window.callGenerate(options) + * + * @param {Object} options - 生成选项 + * @returns {Promise} 生成结果 + * + * @example + * // iframe 中的调用方式: + * const res = await window.callGenerate({ + * components: { list: ['ALL_PREON'] }, + * userInput: '你好', + * streaming: { enabled: true }, + * api: { inherit: true } + * }); + * + * // 全局调用方式(完全一致): + * const res = await window.LittleWhiteBox.callGenerate({ + * components: { list: ['ALL_PREON'] }, + * userInput: '你好', + * streaming: { enabled: true }, + * api: { inherit: true } + * }); + */ + window.LittleWhiteBox.callGenerate = async function(options) { + return new Promise((resolve, reject) => { + const requestId = `global-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const streamingEnabled = options?.streaming?.enabled !== false; + + // 处理流式回调 + let onChunkCallback = null; + if (streamingEnabled && typeof options?.streaming?.onChunk === 'function') { + onChunkCallback = options.streaming.onChunk; + } + + // 监听响应 + const listener = (event) => { + const data = event.data; + if (!data || data.source !== SOURCE_TAG || data.id !== requestId) return; + + if (data.type === 'generateStreamChunk' && onChunkCallback) { + // 流式文本块回调 + try { + onChunkCallback(data.chunk, data.accumulated); + } catch (err) { + console.error('[callGenerate] onChunk callback error:', err); + } + } else if (data.type === 'generateStreamComplete') { + window.removeEventListener('message', listener); + resolve(data.result); + } else if (data.type === 'generateResult') { + window.removeEventListener('message', listener); + resolve(data.result); + } else if (data.type === 'generateStreamError' || data.type === 'generateError') { + window.removeEventListener('message', listener); + reject(data.error); + } + }; + + // eslint-disable-next-line no-restricted-syntax -- local listener for internal request flow. + window.addEventListener('message', listener); + + // 发送请求 + handleGenerateRequest(options, requestId, window).catch(err => { + window.removeEventListener('message', listener); + reject(err); + }); + }); + }; + + /** + * 取消指定会话 + * @param {string} sessionId - 会话 ID(如 'xb1', 'xb2' 等) + */ + window.LittleWhiteBox.callGenerate.cancel = function(sessionId) { + callGenerateService.cancel(sessionId); + }; + + /** + * 清理所有会话 + */ + window.LittleWhiteBox.callGenerate.cleanup = function() { + callGenerateService.cleanup(); + }; + + // 保持向后兼容:保留原有的内部接口 + window.LittleWhiteBox._internal = { + service: callGenerateService, + handleGenerateRequest, + init: initCallGenerateHostBridge, + cleanup: cleanupCallGenerateHostBridge + }; +} diff --git a/worldbook-bridge.js b/worldbook-bridge.js new file mode 100644 index 0000000..87078cc --- /dev/null +++ b/worldbook-bridge.js @@ -0,0 +1,902 @@ +// @ts-nocheck + +import { eventSource, event_types } from "../../../../../script.js"; +import { getContext } from "../../../../st-context.js"; +import { xbLog } from "../core/debug-core.js"; +import { + loadWorldInfo, + saveWorldInfo, + reloadEditor, + updateWorldInfoList, + createNewWorldInfo, + createWorldInfoEntry, + deleteWorldInfoEntry, + newWorldInfoEntryTemplate, + setWIOriginalDataValue, + originalWIDataKeyMap, + METADATA_KEY, + world_info, + selected_world_info, + world_names, + onWorldInfoChange, +} from "../../../../world-info.js"; +import { getCharaFilename, findChar } from "../../../../utils.js"; + +const SOURCE_TAG = "xiaobaix-host"; +const resolveTargetOrigin = (origin) => { + if (typeof origin === 'string' && origin) return origin; + try { return window.location.origin; } catch { return '*'; } +}; + +function isString(value) { + return typeof value === 'string'; +} + +function parseStringArray(input) { + if (input === undefined || input === null) return []; + const str = String(input).trim(); + try { + if (str.startsWith('[')) { + const arr = JSON.parse(str); + return Array.isArray(arr) ? arr.map(x => String(x).trim()).filter(Boolean) : []; + } + } catch {} + return str.split(',').map(x => x.trim()).filter(Boolean); +} + +function isTrueBoolean(value) { + const v = String(value).trim().toLowerCase(); + return v === 'true' || v === '1' || v === 'on' || v === 'yes'; +} + +function isFalseBoolean(value) { + const v = String(value).trim().toLowerCase(); + return v === 'false' || v === '0' || v === 'off' || v === 'no'; +} + +function ensureTimedWorldInfo(ctx) { + if (!ctx.chatMetadata.timedWorldInfo) ctx.chatMetadata.timedWorldInfo = {}; + return ctx.chatMetadata.timedWorldInfo; +} + +class WorldbookBridgeService { + constructor() { + this._listener = null; + this._forwardEvents = false; + this._attached = false; + this._allowedOrigins = ['*']; // Default: allow all origins + } + + setAllowedOrigins(origins) { + this._allowedOrigins = Array.isArray(origins) ? origins : [origins]; + } + + isOriginAllowed(origin) { + if (this._allowedOrigins.includes('*')) return true; + return this._allowedOrigins.some(allowed => { + if (allowed === origin) return true; + // Support wildcard subdomains like *.example.com + if (allowed.startsWith('*.')) { + const domain = allowed.slice(2); + return origin.endsWith('.' + domain) || origin === domain; + } + return false; + }); + } + + normalizeError(err, fallbackCode = 'API_ERROR', details = null) { + try { + if (!err) return { code: fallbackCode, message: 'Unknown error', details }; + if (typeof err === 'string') return { code: fallbackCode, message: err, details }; + const msg = err?.message || String(err); + return { code: fallbackCode, message: msg, details }; + } catch { + return { code: fallbackCode, message: 'Error serialization failed', details }; + } + } + + sendResult(target, requestId, result, targetOrigin = null) { + try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookResult', id: requestId, result }, resolveTargetOrigin(targetOrigin)); } catch {} + } + + sendError(target, requestId, err, fallbackCode = 'API_ERROR', details = null, targetOrigin = null) { + const e = this.normalizeError(err, fallbackCode, details); + try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookError', id: requestId, error: e }, resolveTargetOrigin(targetOrigin)); } catch {} + } + + postEvent(event, payload) { + try { window?.postMessage({ source: SOURCE_TAG, type: 'worldbookEvent', event, payload }, resolveTargetOrigin()); } catch {} + } + + async ensureWorldExists(name, autoCreate) { + if (!isString(name) || !name.trim()) throw new Error('MISSING_PARAMS'); + if (world_names?.includes(name)) return name; + if (!autoCreate) throw new Error(`Worldbook not found: ${name}`); + await createNewWorldInfo(name, { interactive: false }); + await updateWorldInfoList(); + return name; + } + + // ===== Basic actions ===== + async getChatBook(params) { + const ctx = getContext(); + const name = ctx.chatMetadata?.[METADATA_KEY]; + if (name && world_names?.includes(name)) return name; + const desired = isString(params?.name) ? String(params.name) : null; + const newName = desired && !world_names.includes(desired) + ? desired + : `Chat Book ${ctx.getCurrentChatId?.() || ''}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64); + await createNewWorldInfo(newName, { interactive: false }); + ctx.chatMetadata[METADATA_KEY] = newName; + await ctx.saveMetadata(); + return newName; + } + + async getGlobalBooks() { + if (!selected_world_info?.length) return JSON.stringify([]); + return JSON.stringify(selected_world_info.slice()); + } + + async listWorldbooks() { + return Array.isArray(world_names) ? world_names.slice() : []; + } + + async getPersonaBook() { + const ctx = getContext(); + return ctx.powerUserSettings?.persona_description_lorebook || ''; + } + + async getCharBook(params) { + const ctx = getContext(); + const type = String(params?.type ?? 'primary').toLowerCase(); + let characterName = params?.name ?? null; + if (!characterName) { + const active = ctx.characters?.[ctx.characterId]; + characterName = active?.avatar || active?.name || ''; + } + const character = findChar({ name: characterName, allowAvatar: true, preferCurrentChar: false, quiet: true }); + if (!character) return type === 'primary' ? '' : JSON.stringify([]); + + const books = []; + if (type === 'all' || type === 'primary') { + books.push(character.data?.extensions?.world); + } + if (type === 'all' || type === 'additional') { + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); + if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) books.push(...extraCharLore.extraBooks); + } + if (type === 'primary') return books[0] ?? ''; + return JSON.stringify(books.filter(Boolean)); + } + + async world(params) { + const state = params?.state ?? undefined; // 'on'|'off'|'toggle'|undefined + const silent = !!params?.silent; + const name = isString(params?.name) ? params.name : ''; + // Use internal callback to ensure parity with STscript behavior + await onWorldInfoChange({ state, silent }, name); + return ''; + } + + // ===== Entries ===== + async findEntry(params) { + const file = params?.file; + const field = params?.field || 'key'; + const text = String(params?.text ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return ''; + const entries = Object.values(data.entries); + if (!entries.length) return ''; + + let needle = text; + if (typeof newWorldInfoEntryTemplate[field] === 'boolean') { + if (isTrueBoolean(text)) needle = 'true'; + else if (isFalseBoolean(text)) needle = 'false'; + } + + let FuseRef = null; + try { FuseRef = window?.Fuse || Fuse; } catch {} + if (FuseRef) { + const fuse = new FuseRef(entries, { keys: [{ name: field, weight: 1 }], includeScore: true, threshold: 0.3 }); + const results = fuse.search(needle); + const uid = results?.[0]?.item?.uid; + return uid === undefined ? '' : String(uid); + } else { + // Fallback: simple includes on stringified field + const f = entries.find(e => String((Array.isArray(e[field]) ? e[field].join(' ') : e[field]) ?? '').toLowerCase().includes(needle.toLowerCase())); + return f?.uid !== undefined ? String(f.uid) : ''; + } + } + + async getEntryField(params) { + const file = params?.file; + const field = params?.field || 'content'; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return ''; + const entry = data.entries[uid]; + if (!entry) return ''; + if (newWorldInfoEntryTemplate[field] === undefined) return ''; + + const ctx = getContext(); + const tags = ctx.tags || []; + + let fieldValue; + switch (field) { + case 'characterFilterNames': + fieldValue = entry.characterFilter ? entry.characterFilter.names : undefined; + if (Array.isArray(fieldValue)) { + // Map avatar keys back to friendly names if possible (best-effort) + return JSON.stringify(fieldValue.slice()); + } + break; + case 'characterFilterTags': + fieldValue = entry.characterFilter ? entry.characterFilter.tags : undefined; + if (!Array.isArray(fieldValue)) return ''; + return JSON.stringify(tags.filter(tag => fieldValue.includes(tag.id)).map(tag => tag.name)); + case 'characterFilterExclude': + fieldValue = entry.characterFilter ? entry.characterFilter.isExclude : undefined; + break; + default: + fieldValue = entry[field]; + } + + if (fieldValue === undefined) return ''; + if (Array.isArray(fieldValue)) return JSON.stringify(fieldValue.map(x => String(x))); + return String(fieldValue); + } + + async setEntryField(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const field = params?.field || 'content'; + let value = params?.value; + if (value === undefined) throw new Error('MISSING_PARAMS'); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + if (newWorldInfoEntryTemplate[field] === undefined) throw new Error('VALIDATION_FAILED: field'); + + const ctx = getContext(); + const tags = ctx.tags || []; + + const ensureCharacterFilterObject = () => { + if (!entry.characterFilter) { + Object.assign(entry, { characterFilter: { isExclude: false, names: [], tags: [] } }); + } + }; + + // Unescape escaped special chars (compat with STscript input style) + value = String(value).replace(/\\([{}|])/g, '$1'); + + switch (field) { + case 'characterFilterNames': { + ensureCharacterFilterObject(); + const names = parseStringArray(value); + const avatars = names + .map((name) => findChar({ name, allowAvatar: true, preferCurrentChar: false, quiet: true })?.avatar) + .filter(Boolean); + // Convert to canonical filenames + entry.characterFilter.names = avatars + .map((avatarKey) => getCharaFilename(null, { manualAvatarKey: avatarKey })) + .filter(Boolean); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + case 'characterFilterTags': { + ensureCharacterFilterObject(); + const tagNames = parseStringArray(value); + entry.characterFilter.tags = tags.filter((t) => tagNames.includes(t.name)).map((t) => t.id); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + case 'characterFilterExclude': { + ensureCharacterFilterObject(); + entry.characterFilter.isExclude = isTrueBoolean(value); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + default: { + if (Array.isArray(entry[field])) { + entry[field] = parseStringArray(value); + } else if (typeof entry[field] === 'boolean') { + entry[field] = isTrueBoolean(value); + } else if (typeof entry[field] === 'number') { + entry[field] = Number(value); + } else { + entry[field] = String(value); + } + if (originalWIDataKeyMap[field]) { + setWIOriginalDataValue(data, uid, originalWIDataKeyMap[field], entry[field]); + } + break; + } + } + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_UPDATED', { file, uid, fields: [field] }); + return ''; + } + + async createEntry(params) { + const file = params?.file; + const key = params?.key; + const content = params?.content; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = createWorldInfoEntry(file, data); + if (key) { entry.key.push(String(key)); entry.addMemo = true; entry.comment = String(key); } + if (content) entry.content = String(content); + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_CREATED', { file, uid: entry.uid }); + return String(entry.uid); + } + + async listEntries(params) { + const file = params?.file; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return []; + return Object.values(data.entries).map(e => ({ + uid: e.uid, + comment: e.comment || '', + key: Array.isArray(e.key) ? e.key.slice() : [], + keysecondary: Array.isArray(e.keysecondary) ? e.keysecondary.slice() : [], + position: e.position, + depth: e.depth, + order: e.order, + probability: e.probability, + useProbability: !!e.useProbability, + disable: !!e.disable, + })); + } + + async deleteEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const ok = await deleteWorldInfoEntry(data, uid, { silent: true }); + if (ok) { + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_DELETED', { file, uid }); + } + return ok ? 'ok' : ''; + } + + // ===== Enhanced Entry Operations ===== + async getEntryAll(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + const result = {}; + + // Get all template fields + for (const field of Object.keys(newWorldInfoEntryTemplate)) { + try { + result[field] = await this.getEntryField({ file, uid, field }); + } catch { + result[field] = ''; + } + } + + return result; + } + + async batchSetEntryFields(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const fields = params?.fields || {}; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (typeof fields !== 'object' || !fields) throw new Error('VALIDATION_FAILED: fields must be object'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + // Apply all field changes + for (const [field, value] of Object.entries(fields)) { + try { + await this.setEntryField({ file, uid, field, value }); + } catch (err) { + // Continue with other fields, but collect errors + console.warn(`Failed to set field ${field}:`, err); + } + } + + this.postEvent('ENTRY_UPDATED', { file, uid, fields: Object.keys(fields) }); + return 'ok'; + } + + async cloneEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const newKey = params?.newKey; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const sourceEntry = data.entries[uid]; + if (!sourceEntry) throw new Error('NOT_FOUND'); + + // Create new entry with same data + const newEntry = createWorldInfoEntry(file, data); + + // Copy all fields from source (except uid which is auto-generated) + for (const [key, value] of Object.entries(sourceEntry)) { + if (key !== 'uid') { + if (Array.isArray(value)) { + newEntry[key] = value.slice(); + } else if (typeof value === 'object' && value !== null) { + newEntry[key] = JSON.parse(JSON.stringify(value)); + } else { + newEntry[key] = value; + } + } + } + + // Update key if provided + if (newKey) { + newEntry.key = [String(newKey)]; + newEntry.comment = `Copy of: ${String(newKey)}`; + } else if (sourceEntry.comment) { + newEntry.comment = `Copy of: ${sourceEntry.comment}`; + } + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_CREATED', { file, uid: newEntry.uid, clonedFrom: uid }); + return String(newEntry.uid); + } + + async moveEntry(params) { + const sourceFile = params?.sourceFile; + const targetFile = params?.targetFile; + const uid = String(params?.uid ?? '').trim(); + if (!sourceFile || !world_names.includes(sourceFile)) throw new Error('VALIDATION_FAILED: sourceFile'); + if (!targetFile || !world_names.includes(targetFile)) throw new Error('VALIDATION_FAILED: targetFile'); + + const sourceData = await loadWorldInfo(sourceFile); + const targetData = await loadWorldInfo(targetFile); + if (!sourceData?.entries || !targetData?.entries) throw new Error('NOT_FOUND'); + + const entry = sourceData.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + // Create new entry in target with same data + const newEntry = createWorldInfoEntry(targetFile, targetData); + for (const [key, value] of Object.entries(entry)) { + if (key !== 'uid') { + if (Array.isArray(value)) { + newEntry[key] = value.slice(); + } else if (typeof value === 'object' && value !== null) { + newEntry[key] = JSON.parse(JSON.stringify(value)); + } else { + newEntry[key] = value; + } + } + } + + // Remove from source + delete sourceData.entries[uid]; + + // Save both files + await saveWorldInfo(sourceFile, sourceData, true); + await saveWorldInfo(targetFile, targetData, true); + reloadEditor(sourceFile); + reloadEditor(targetFile); + + this.postEvent('ENTRY_MOVED', { + sourceFile, + targetFile, + oldUid: uid, + newUid: newEntry.uid + }); + return String(newEntry.uid); + } + + async reorderEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const newOrder = Number(params?.newOrder ?? 0); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + entry.order = newOrder; + setWIOriginalDataValue(data, uid, 'order', newOrder); + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_UPDATED', { file, uid, fields: ['order'] }); + return 'ok'; + } + + // ===== File-level Operations ===== + async renameWorldbook(params) { + const oldName = params?.oldName; + const newName = params?.newName; + if (!oldName || !world_names.includes(oldName)) throw new Error('VALIDATION_FAILED: oldName'); + if (!newName || world_names.includes(newName)) throw new Error('VALIDATION_FAILED: newName already exists'); + + // This is a complex operation that would require ST core support + // For now, we'll throw an error indicating it's not implemented + throw new Error('NOT_IMPLEMENTED: renameWorldbook requires ST core support'); + } + + async deleteWorldbook(params) { + const name = params?.name; + if (!name || !world_names.includes(name)) throw new Error('VALIDATION_FAILED: name'); + + // This is a complex operation that would require ST core support + // For now, we'll throw an error indicating it's not implemented + throw new Error('NOT_IMPLEMENTED: deleteWorldbook requires ST core support'); + } + + async exportWorldbook(params) { + const file = params?.file; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data) throw new Error('NOT_FOUND'); + + return JSON.stringify(data, null, 2); + } + + async importWorldbook(params) { + const name = params?.name; + const jsonData = params?.data; + const overwrite = !!params?.overwrite; + + if (!name) throw new Error('VALIDATION_FAILED: name'); + if (!jsonData) throw new Error('VALIDATION_FAILED: data'); + + if (world_names.includes(name) && !overwrite) { + throw new Error('VALIDATION_FAILED: worldbook exists and overwrite=false'); + } + + let data; + try { + data = JSON.parse(jsonData); + } catch { + throw new Error('VALIDATION_FAILED: invalid JSON data'); + } + + if (!world_names.includes(name)) { + await createNewWorldInfo(name, { interactive: false }); + await updateWorldInfoList(); + } + + await saveWorldInfo(name, data, true); + reloadEditor(name); + this.postEvent('WORLDBOOK_IMPORTED', { name }); + return 'ok'; + } + + // ===== Timed effects (minimal parity) ===== + async wiGetTimedEffect(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' + const format = String(params?.format ?? 'bool').trim().toLowerCase(); // 'bool'|'number' + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (!uid) throw new Error('MISSING_PARAMS'); + if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); + const ctx = getContext(); + const key = `${file}.${uid}`; + const t = ensureTimedWorldInfo(ctx); + const store = t[effect] || {}; + const meta = store[key]; + if (format === 'number') { + const remaining = meta ? Math.max(0, Number(meta.end || 0) - (ctx.chat?.length || 0)) : 0; + return String(remaining); + } + return String(!!meta); + } + + async wiSetTimedEffect(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' + let value = params?.value; // 'toggle'|'true'|'false'|boolean + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (!uid) throw new Error('MISSING_PARAMS'); + if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + if (!entry[effect]) throw new Error('VALIDATION_FAILED: entry has no effect configured'); + + const ctx = getContext(); + const key = `${file}.${uid}`; + const t = ensureTimedWorldInfo(ctx); + if (!t[effect] || typeof t[effect] !== 'object') t[effect] = {}; + const store = t[effect]; + const current = !!store[key]; + + let newState; + const vs = String(value ?? '').trim().toLowerCase(); + if (vs === 'toggle' || vs === '') newState = !current; + else if (isTrueBoolean(vs)) newState = true; + else if (isFalseBoolean(vs)) newState = false; + else newState = current; + + if (newState) { + const duration = Number(entry[effect]) || 0; + store[key] = { end: (ctx.chat?.length || 0) + duration, world: file, uid }; + } else { + delete store[key]; + } + await ctx.saveMetadata(); + return ''; + } + + // ===== Bind / Unbind ===== + async bindWorldbookToChat(params) { + const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); + const ctx = getContext(); + ctx.chatMetadata[METADATA_KEY] = name; + await ctx.saveMetadata(); + return { name }; + } + + async unbindWorldbookFromChat() { + const ctx = getContext(); + delete ctx.chatMetadata[METADATA_KEY]; + await ctx.saveMetadata(); + return { name: '' }; + } + + async bindWorldbookToCharacter(params) { + const ctx = getContext(); + const target = String(params?.target ?? 'primary').toLowerCase(); + const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); + + const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; + const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); + if (!character) throw new Error('NOT_FOUND: character'); + + if (target === 'primary') { + if (typeof ctx.writeExtensionField === 'function') { + await ctx.writeExtensionField('world', name); + } else { + // Fallback: set on active character only + const active = ctx.characters?.[ctx.characterId]; + if (active) { + active.data = active.data || {}; + active.data.extensions = active.data.extensions || {}; + active.data.extensions.world = name; + } + } + return { primary: name }; + } + + // additional => world_info.charLore + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + let list = world_info.charLore || []; + const idx = list.findIndex(e => e.name === fileName); + if (idx === -1) { + list.push({ name: fileName, extraBooks: [name] }); + } else { + const eb = new Set(list[idx].extraBooks || []); + eb.add(name); + list[idx].extraBooks = Array.from(eb); + } + world_info.charLore = list; + getContext().saveSettingsDebounced?.(); + return { additional: (world_info.charLore.find(e => e.name === fileName)?.extraBooks) || [name] }; + } + + async unbindWorldbookFromCharacter(params) { + const ctx = getContext(); + const target = String(params?.target ?? 'primary').toLowerCase(); + const name = isString(params?.worldbookName) ? params.worldbookName : null; + const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; + const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); + if (!character) throw new Error('NOT_FOUND: character'); + + const result = {}; + if (target === 'primary' || target === 'all') { + if (typeof ctx.writeExtensionField === 'function') { + await ctx.writeExtensionField('world', ''); + } else { + const active = ctx.characters?.[ctx.characterId]; + if (active?.data?.extensions) active.data.extensions.world = ''; + } + result.primary = ''; + } + + if (target === 'additional' || target === 'all') { + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + let list = world_info.charLore || []; + const idx = list.findIndex(e => e.name === fileName); + if (idx !== -1) { + if (name) { + list[idx].extraBooks = (list[idx].extraBooks || []).filter(e => e !== name); + if (list[idx].extraBooks.length === 0) list.splice(idx, 1); + } else { + // remove all + list.splice(idx, 1); + } + world_info.charLore = list; + getContext().saveSettingsDebounced?.(); + result.additional = world_info.charLore.find(e => e.name === fileName)?.extraBooks || []; + } else { + result.additional = []; + } + } + return result; + } + + // ===== Dispatcher ===== + async handleRequest(action, params) { + switch (action) { + // Basic operations + case 'getChatBook': return await this.getChatBook(params); + case 'getGlobalBooks': return await this.getGlobalBooks(params); + case 'listWorldbooks': return await this.listWorldbooks(params); + case 'getPersonaBook': return await this.getPersonaBook(params); + case 'getCharBook': return await this.getCharBook(params); + case 'world': return await this.world(params); + + // Entry operations + case 'findEntry': return await this.findEntry(params); + case 'getEntryField': return await this.getEntryField(params); + case 'setEntryField': return await this.setEntryField(params); + case 'createEntry': return await this.createEntry(params); + case 'listEntries': return await this.listEntries(params); + case 'deleteEntry': return await this.deleteEntry(params); + + // Enhanced entry operations + case 'getEntryAll': return await this.getEntryAll(params); + case 'batchSetEntryFields': return await this.batchSetEntryFields(params); + case 'cloneEntry': return await this.cloneEntry(params); + case 'moveEntry': return await this.moveEntry(params); + case 'reorderEntry': return await this.reorderEntry(params); + + // File-level operations + case 'renameWorldbook': return await this.renameWorldbook(params); + case 'deleteWorldbook': return await this.deleteWorldbook(params); + case 'exportWorldbook': return await this.exportWorldbook(params); + case 'importWorldbook': return await this.importWorldbook(params); + + // Timed effects + case 'wiGetTimedEffect': return await this.wiGetTimedEffect(params); + case 'wiSetTimedEffect': return await this.wiSetTimedEffect(params); + + // Binding operations + case 'bindWorldbookToChat': return await this.bindWorldbookToChat(params); + case 'unbindWorldbookFromChat': return await this.unbindWorldbookFromChat(params); + case 'bindWorldbookToCharacter': return await this.bindWorldbookToCharacter(params); + case 'unbindWorldbookFromCharacter': return await this.unbindWorldbookFromCharacter(params); + + default: throw new Error('INVALID_ACTION'); + } + } + + attachEventsForwarding() { + if (this._forwardEvents) return; + this._onWIUpdated = (name, data) => this.postEvent('WORLDBOOK_UPDATED', { name }); + this._onWISettings = () => this.postEvent('WORLDBOOK_SETTINGS_UPDATED', {}); + this._onWIActivated = (entries) => this.postEvent('WORLDBOOK_ACTIVATED', { entries }); + eventSource.on(event_types.WORLDINFO_UPDATED, this._onWIUpdated); + eventSource.on(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); + eventSource.on(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); + this._forwardEvents = true; + } + + detachEventsForwarding() { + if (!this._forwardEvents) return; + try { eventSource.removeListener(event_types.WORLDINFO_UPDATED, this._onWIUpdated); } catch {} + try { eventSource.removeListener(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); } catch {} + try { eventSource.removeListener(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); } catch {} + this._forwardEvents = false; + } + + init({ forwardEvents = false, allowedOrigins = null } = {}) { + if (this._attached) return; + if (allowedOrigins) this.setAllowedOrigins(allowedOrigins); + + const self = this; + this._listener = async function (event) { + try { + // Security check: validate origin + if (!self.isOriginAllowed(event.origin)) { + console.warn('Worldbook bridge: Rejected request from unauthorized origin:', event.origin); + return; + } + + const data = event && event.data || {}; + if (!data || data.type !== 'worldbookRequest') return; + const id = data.id; + const action = data.action; + const params = data.params || {}; + try { + try { + if (xbLog.isEnabled?.()) { + xbLog.info('worldbookBridge', `worldbookRequest id=${id} action=${String(action || '')}`); + } + } catch {} + const result = await self.handleRequest(action, params); + self.sendResult(event.source || window, id, result, event.origin); + } catch (err) { + try { xbLog.error('worldbookBridge', `worldbookRequest failed id=${id} action=${String(action || '')}`, err); } catch {} + self.sendError(event.source || window, id, err, 'API_ERROR', null, event.origin); + } + } catch {} + }; + // eslint-disable-next-line no-restricted-syntax -- validated by isOriginAllowed before handling. + try { window.addEventListener('message', this._listener); } catch {} + this._attached = true; + if (forwardEvents) this.attachEventsForwarding(); + } + + cleanup() { + if (!this._attached) return; + try { xbLog.info('worldbookBridge', 'cleanup'); } catch {} + try { window.removeEventListener('message', this._listener); } catch {} + this._attached = false; + this._listener = null; + this.detachEventsForwarding(); + } +} + +const worldbookBridge = new WorldbookBridgeService(); + +export function initWorldbookHostBridge(options) { + try { xbLog.info('worldbookBridge', 'initWorldbookHostBridge'); } catch {} + try { worldbookBridge.init(options || {}); } catch {} +} + +export function cleanupWorldbookHostBridge() { + try { xbLog.info('worldbookBridge', 'cleanupWorldbookHostBridge'); } catch {} + try { worldbookBridge.cleanup(); } catch {} +} + +if (typeof window !== 'undefined') { + Object.assign(window, { + xiaobaixWorldbookService: worldbookBridge, + initWorldbookHostBridge, + cleanupWorldbookHostBridge, + setWorldbookBridgeOrigins: (origins) => worldbookBridge.setAllowedOrigins(origins) + }); + try { initWorldbookHostBridge({ forwardEvents: true }); } catch {} + try { + window.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); + } catch (_) {} + }); + document.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); + } catch (_) {} + }); + window.addEventListener('beforeunload', () => { try { cleanupWorldbookHostBridge(); } catch (_) {} }); + } catch (_) {} +} + + diff --git a/wrapper-iframe.js b/wrapper-iframe.js new file mode 100644 index 0000000..00ce0cf --- /dev/null +++ b/wrapper-iframe.js @@ -0,0 +1,116 @@ +(function(){ + function defineCallGenerate(){ + var parentOrigin; + try{parentOrigin=new URL(document.referrer).origin}catch(_){parentOrigin='*'} + function sanitizeOptions(options){ + try{ + return JSON.parse(JSON.stringify(options,function(k,v){return(typeof v==='function')?undefined:v})) + }catch(_){ + try{ + const seen=new WeakSet(); + const clone=(val)=>{ + if(val===null||val===undefined)return val; + const t=typeof val; + if(t==='function')return undefined; + if(t!=='object')return val; + if(seen.has(val))return undefined; + seen.add(val); + if(Array.isArray(val)){ + const arr=[];for(let i=0;i