Upload files to "/"

This commit is contained in:
2026-01-17 15:44:04 +00:00
parent 73b8a6d23f
commit e1f4191b57
3 changed files with 2568 additions and 0 deletions

902
worldbook-bridge.js Normal file
View File

@@ -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 (_) {}
}