903 lines
37 KiB
JavaScript
903 lines
37 KiB
JavaScript
// @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 (_) {}
|
|
}
|
|
|
|
|