diff --git a/worldbook-bridge.js b/worldbook-bridge.js deleted file mode 100644 index 87078cc..0000000 --- a/worldbook-bridge.js +++ /dev/null @@ -1,902 +0,0 @@ -// @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 (_) {} -} - -