Initial commit

This commit is contained in:
TYt50
2026-01-17 16:34:39 +08:00
commit 73b8a6d23f
72 changed files with 45972 additions and 0 deletions

7
core/constants.js Normal file
View File

@@ -0,0 +1,7 @@
/**
* LittleWhiteBox 共享常量
*/
export const EXT_ID = "LittleWhiteBox";
export const EXT_NAME = "小白X";
export const extensionFolderPath = `scripts/extensions/third-party/${EXT_ID}`;

322
core/debug-core.js Normal file
View File

@@ -0,0 +1,322 @@
import { EventCenter } from "./event-manager.js";
const DEFAULT_MAX_LOGS = 200;
function now() {
return Date.now();
}
function safeStringify(value) {
try {
if (typeof value === "string") return value;
return JSON.stringify(value);
} catch {
try {
return String(value);
} catch {
return "[unstringifiable]";
}
}
}
function errorToStack(err) {
try {
if (!err) return null;
if (typeof err === "string") return err;
if (err && typeof err.stack === "string") return err.stack;
return safeStringify(err);
} catch {
return null;
}
}
class LoggerCore {
constructor() {
this._enabled = false;
this._buffer = [];
this._maxSize = DEFAULT_MAX_LOGS;
this._seq = 0;
this._originalConsole = null;
this._originalOnError = null;
this._originalOnUnhandledRejection = null;
this._mounted = false;
}
setMaxSize(n) {
const v = Number.parseInt(n, 10);
if (Number.isFinite(v) && v > 0) this._maxSize = v;
if (this._buffer.length > this._maxSize) {
this._buffer.splice(0, this._buffer.length - this._maxSize);
}
}
isEnabled() {
return !!this._enabled;
}
enable() {
if (this._enabled) return;
this._enabled = true;
this._mountGlobalHooks();
}
disable() {
this._enabled = false;
this.clear();
this._unmountGlobalHooks();
}
clear() {
this._buffer.length = 0;
}
getAll() {
return this._buffer.slice();
}
export() {
return JSON.stringify(
{
version: 1,
exportedAt: now(),
maxSize: this._maxSize,
logs: this.getAll(),
},
null,
2
);
}
_push(entry) {
if (!this._enabled) return;
this._buffer.push(entry);
if (this._buffer.length > this._maxSize) {
this._buffer.splice(0, this._buffer.length - this._maxSize);
}
}
_log(level, moduleId, message, err) {
if (!this._enabled) return;
const id = ++this._seq;
const timestamp = now();
const stack = err ? errorToStack(err) : null;
this._push({
id,
timestamp,
level,
module: moduleId || "unknown",
message: typeof message === "string" ? message : safeStringify(message),
stack,
});
}
info(moduleId, message) {
this._log("info", moduleId, message, null);
}
warn(moduleId, message) {
this._log("warn", moduleId, message, null);
}
error(moduleId, message, err) {
this._log("error", moduleId, message, err || null);
}
_mountGlobalHooks() {
if (this._mounted) return;
this._mounted = true;
if (typeof window !== "undefined") {
try {
this._originalOnError = window.onerror;
} catch {}
try {
this._originalOnUnhandledRejection = window.onunhandledrejection;
} catch {}
try {
window.onerror = (message, source, lineno, colno, error) => {
try {
const loc = source ? `${source}:${lineno || 0}:${colno || 0}` : "";
this.error("window", `${String(message || "error")} ${loc}`.trim(), error || null);
} catch {}
try {
if (typeof this._originalOnError === "function") {
return this._originalOnError(message, source, lineno, colno, error);
}
} catch {}
return false;
};
} catch {}
try {
window.onunhandledrejection = (event) => {
try {
const reason = event?.reason;
this.error("promise", "Unhandled promise rejection", reason || null);
} catch {}
try {
if (typeof this._originalOnUnhandledRejection === "function") {
return this._originalOnUnhandledRejection(event);
}
} catch {}
return undefined;
};
} catch {}
}
if (typeof console !== "undefined" && console) {
this._originalConsole = this._originalConsole || {
warn: console.warn?.bind(console),
error: console.error?.bind(console),
};
try {
if (typeof this._originalConsole.warn === "function") {
console.warn = (...args) => {
try {
const msg = args.map(a => (typeof a === "string" ? a : safeStringify(a))).join(" ");
this.warn("console", msg);
} catch {}
return this._originalConsole.warn(...args);
};
}
} catch {}
try {
if (typeof this._originalConsole.error === "function") {
console.error = (...args) => {
try {
const msg = args.map(a => (typeof a === "string" ? a : safeStringify(a))).join(" ");
this.error("console", msg, null);
} catch {}
return this._originalConsole.error(...args);
};
}
} catch {}
}
}
_unmountGlobalHooks() {
if (!this._mounted) return;
this._mounted = false;
if (typeof window !== "undefined") {
try {
if (this._originalOnError !== null && this._originalOnError !== undefined) {
window.onerror = this._originalOnError;
} else {
window.onerror = null;
}
} catch {}
try {
if (this._originalOnUnhandledRejection !== null && this._originalOnUnhandledRejection !== undefined) {
window.onunhandledrejection = this._originalOnUnhandledRejection;
} else {
window.onunhandledrejection = null;
}
} catch {}
}
if (typeof console !== "undefined" && console && this._originalConsole) {
try {
if (this._originalConsole.warn) console.warn = this._originalConsole.warn;
} catch {}
try {
if (this._originalConsole.error) console.error = this._originalConsole.error;
} catch {}
}
}
}
const logger = new LoggerCore();
export const xbLog = {
enable: () => logger.enable(),
disable: () => logger.disable(),
isEnabled: () => logger.isEnabled(),
setMaxSize: (n) => logger.setMaxSize(n),
info: (moduleId, message) => logger.info(moduleId, message),
warn: (moduleId, message) => logger.warn(moduleId, message),
error: (moduleId, message, err) => logger.error(moduleId, message, err),
getAll: () => logger.getAll(),
clear: () => logger.clear(),
export: () => logger.export(),
};
export const CacheRegistry = (() => {
const _registry = new Map();
function register(moduleId, cacheInfo) {
if (!moduleId || !cacheInfo || typeof cacheInfo !== "object") return;
_registry.set(String(moduleId), cacheInfo);
}
function unregister(moduleId) {
if (!moduleId) return;
_registry.delete(String(moduleId));
}
function getStats() {
const out = [];
for (const [moduleId, info] of _registry.entries()) {
let size = null;
let bytes = null;
let name = null;
let hasDetail = false;
try { name = info?.name || moduleId; } catch { name = moduleId; }
try { size = typeof info?.getSize === "function" ? info.getSize() : null; } catch { size = null; }
try { bytes = typeof info?.getBytes === "function" ? info.getBytes() : null; } catch { bytes = null; }
try { hasDetail = typeof info?.getDetail === "function"; } catch { hasDetail = false; }
out.push({ moduleId, name, size, bytes, hasDetail });
}
return out;
}
function getDetail(moduleId) {
const info = _registry.get(String(moduleId));
if (!info || typeof info.getDetail !== "function") return null;
try {
return info.getDetail();
} catch {
return null;
}
}
function clear(moduleId) {
const info = _registry.get(String(moduleId));
if (!info || typeof info.clear !== "function") return false;
try {
info.clear();
return true;
} catch {
return false;
}
}
function clearAll() {
const results = {};
for (const moduleId of _registry.keys()) {
results[moduleId] = clear(moduleId);
}
return results;
}
return { register, unregister, getStats, getDetail, clear, clearAll };
})();
export function enableDebugMode() {
xbLog.enable();
try { EventCenter.enableDebug?.(); } catch {}
}
export function disableDebugMode() {
xbLog.disable();
try { EventCenter.disableDebug?.(); } catch {}
}
if (typeof window !== "undefined") {
window.xbLog = xbLog;
window.xbCacheRegistry = CacheRegistry;
}

241
core/event-manager.js Normal file
View File

@@ -0,0 +1,241 @@
import { eventSource, event_types } from "../../../../../script.js";
const registry = new Map();
const customEvents = new Map();
const handlerWrapperMap = new WeakMap();
export const EventCenter = {
_debugEnabled: false,
_eventHistory: [],
_maxHistory: 100,
_historySeq: 0,
enableDebug() {
this._debugEnabled = true;
},
disableDebug() {
this._debugEnabled = false;
this.clearHistory();
},
getEventHistory() {
return this._eventHistory.slice();
},
clearHistory() {
this._eventHistory.length = 0;
},
_pushHistory(type, eventName, triggerModule, data) {
if (!this._debugEnabled) return;
try {
const now = Date.now();
const last = this._eventHistory[this._eventHistory.length - 1];
if (
last &&
last.type === type &&
last.eventName === eventName &&
now - last.timestamp < 100
) {
last.repeatCount = (last.repeatCount || 1) + 1;
return;
}
const id = ++this._historySeq;
let dataSummary = null;
try {
if (data === undefined) {
dataSummary = "undefined";
} else if (data === null) {
dataSummary = "null";
} else if (typeof data === "string") {
dataSummary = data.length > 120 ? data.slice(0, 120) + "…" : data;
} else if (typeof data === "number" || typeof data === "boolean") {
dataSummary = String(data);
} else if (typeof data === "object") {
const keys = Object.keys(data).slice(0, 6);
dataSummary = `{ ${keys.join(", ")}${keys.length < Object.keys(data).length ? ", …" : ""} }`;
} else {
dataSummary = String(data).slice(0, 80);
}
} catch {
dataSummary = "[unstringifiable]";
}
this._eventHistory.push({
id,
timestamp: now,
type,
eventName,
triggerModule,
dataSummary,
repeatCount: 1,
});
if (this._eventHistory.length > this._maxHistory) {
this._eventHistory.splice(0, this._eventHistory.length - this._maxHistory);
}
} catch {}
},
on(moduleId, eventType, handler) {
if (!moduleId || !eventType || typeof handler !== "function") return;
if (!registry.has(moduleId)) {
registry.set(moduleId, []);
}
const self = this;
const wrappedHandler = function (...args) {
if (self._debugEnabled) {
self._pushHistory("ST_EVENT", eventType, moduleId, args[0]);
}
return handler.apply(this, args);
};
handlerWrapperMap.set(handler, wrappedHandler);
try {
eventSource.on(eventType, wrappedHandler);
registry.get(moduleId).push({ eventType, handler, wrappedHandler });
} catch (e) {
console.error(`[EventCenter] Failed to register ${eventType} for ${moduleId}:`, e);
}
},
onMany(moduleId, eventTypes, handler) {
if (!Array.isArray(eventTypes)) return;
eventTypes.filter(Boolean).forEach((type) => this.on(moduleId, type, handler));
},
off(moduleId, eventType, handler) {
try {
const listeners = registry.get(moduleId);
if (!listeners) return;
const idx = listeners.findIndex((l) => l.eventType === eventType && l.handler === handler);
if (idx === -1) return;
const entry = listeners[idx];
eventSource.removeListener(eventType, entry.wrappedHandler);
listeners.splice(idx, 1);
handlerWrapperMap.delete(handler);
} catch {}
},
cleanup(moduleId) {
const listeners = registry.get(moduleId);
if (!listeners) return;
listeners.forEach(({ eventType, handler, wrappedHandler }) => {
try {
eventSource.removeListener(eventType, wrappedHandler);
handlerWrapperMap.delete(handler);
} catch {}
});
registry.delete(moduleId);
},
cleanupAll() {
for (const moduleId of registry.keys()) {
this.cleanup(moduleId);
}
customEvents.clear();
},
count(moduleId) {
return registry.get(moduleId)?.length || 0;
},
/**
* 获取统计:每个模块注册了多少监听器
*/
stats() {
const stats = {};
for (const [moduleId, listeners] of registry) {
stats[moduleId] = listeners.length;
}
return stats;
},
/**
* 获取详细信息:每个模块监听了哪些具体事件
*/
statsDetail() {
const detail = {};
for (const [moduleId, listeners] of registry) {
const eventCounts = {};
for (const l of listeners) {
const t = l.eventType || "unknown";
eventCounts[t] = (eventCounts[t] || 0) + 1;
}
detail[moduleId] = {
total: listeners.length,
events: eventCounts,
};
}
return detail;
},
emit(eventName, data) {
this._pushHistory("CUSTOM", eventName, null, data);
const handlers = customEvents.get(eventName);
if (!handlers) return;
handlers.forEach(({ handler }) => {
try {
handler(data);
} catch {}
});
},
subscribe(moduleId, eventName, handler) {
if (!customEvents.has(eventName)) {
customEvents.set(eventName, []);
}
customEvents.get(eventName).push({ moduleId, handler });
},
unsubscribe(moduleId, eventName) {
const handlers = customEvents.get(eventName);
if (handlers) {
const filtered = handlers.filter((h) => h.moduleId !== moduleId);
if (filtered.length) {
customEvents.set(eventName, filtered);
} else {
customEvents.delete(eventName);
}
}
},
};
export function createModuleEvents(moduleId) {
return {
on: (eventType, handler) => EventCenter.on(moduleId, eventType, handler),
onMany: (eventTypes, handler) => EventCenter.onMany(moduleId, eventTypes, handler),
off: (eventType, handler) => EventCenter.off(moduleId, eventType, handler),
cleanup: () => EventCenter.cleanup(moduleId),
count: () => EventCenter.count(moduleId),
emit: (eventName, data) => EventCenter.emit(eventName, data),
subscribe: (eventName, handler) => EventCenter.subscribe(moduleId, eventName, handler),
unsubscribe: (eventName) => EventCenter.unsubscribe(moduleId, eventName),
};
}
if (typeof window !== "undefined") {
window.xbEventCenter = {
stats: () => EventCenter.stats(),
statsDetail: () => EventCenter.statsDetail(),
modules: () => Array.from(registry.keys()),
history: () => EventCenter.getEventHistory(),
clearHistory: () => EventCenter.clearHistory(),
detail: (moduleId) => {
const listeners = registry.get(moduleId);
if (!listeners) return `模块 "${moduleId}" 未注册`;
return listeners.map((l) => l.eventType).join(", ");
},
help: () =>
console.log(`
📊 小白X 事件管理器调试命令:
xbEventCenter.stats() - 查看所有模块的事件数量
xbEventCenter.statsDetail() - 查看所有模块监听的具体事件
xbEventCenter.modules() - 列出所有已注册模块
xbEventCenter.history() - 查看事件触发历史
xbEventCenter.clearHistory() - 清空事件历史
xbEventCenter.detail('模块名') - 查看模块监听的事件类型
`),
};
}
export { event_types };

27
core/iframe-messaging.js Normal file
View File

@@ -0,0 +1,27 @@
export function getTrustedOrigin() {
return window.location.origin;
}
export function getIframeTargetOrigin(iframe) {
const sandbox = iframe?.getAttribute?.('sandbox') || '';
if (sandbox && !sandbox.includes('allow-same-origin')) return 'null';
return getTrustedOrigin();
}
export function postToIframe(iframe, payload, source, targetOrigin = null) {
if (!iframe?.contentWindow) return false;
const message = source ? { source, ...payload } : payload;
const origin = targetOrigin || getTrustedOrigin();
iframe.contentWindow.postMessage(message, origin);
return true;
}
export function isTrustedIframeEvent(event, iframe) {
return !!iframe && event.origin === getTrustedOrigin() && event.source === iframe.contentWindow;
}
export function isTrustedMessage(event, iframe, expectedSource) {
if (!isTrustedIframeEvent(event, iframe)) return false;
if (expectedSource && event?.data?.source !== expectedSource) return false;
return true;
}

185
core/server-storage.js Normal file
View File

@@ -0,0 +1,185 @@
// ═══════════════════════════════════════════════════════════════════════════
// 服务器文件存储工具
// ═══════════════════════════════════════════════════════════════════════════
import { getRequestHeaders } from '../../../../../script.js';
import { debounce } from '../../../../utils.js';
const toBase64 = (text) => btoa(unescape(encodeURIComponent(text)));
class StorageFile {
constructor(filename, opts = {}) {
this.filename = filename;
this.cache = null;
this._loading = null;
this._dirtyVersion = 0;
this._savedVersion = 0;
this._saving = false;
this._pendingSave = false;
this._retryCount = 0;
this._retryTimer = null;
this._maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : 5;
const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 2000;
this._saveDebounced = debounce(() => this.saveNow({ silent: true }), debounceMs);
}
async load() {
if (this.cache !== null) return this.cache;
if (this._loading) return this._loading;
this._loading = (async () => {
try {
const res = await fetch(`/user/files/${this.filename}`, {
headers: getRequestHeaders(),
cache: 'no-cache',
});
if (!res.ok) {
this.cache = {};
return this.cache;
}
const text = await res.text();
this.cache = text ? (JSON.parse(text) || {}) : {};
} catch {
this.cache = {};
} finally {
this._loading = null;
}
return this.cache;
})();
return this._loading;
}
async get(key, defaultValue = null) {
const data = await this.load();
return data[key] ?? defaultValue;
}
async set(key, value) {
const data = await this.load();
data[key] = value;
this._dirtyVersion++;
this._saveDebounced();
}
async delete(key) {
const data = await this.load();
if (key in data) {
delete data[key];
this._dirtyVersion++;
this._saveDebounced();
}
}
/**
* 立即保存
* @param {Object} options
* @param {boolean} options.silent - 静默模式:失败时不抛异常,返回 false
* @returns {Promise<boolean>} 是否保存成功
*/
async saveNow({ silent = true } = {}) {
// 🔧 核心修复:非静默模式等待当前保存完成
if (this._saving) {
this._pendingSave = true;
if (!silent) {
await this._waitForSaveComplete();
if (this._dirtyVersion > this._savedVersion) {
return this.saveNow({ silent });
}
return this._dirtyVersion === this._savedVersion;
}
return true;
}
if (!this.cache || this._dirtyVersion === this._savedVersion) {
return true;
}
this._saving = true;
this._pendingSave = false;
const versionToSave = this._dirtyVersion;
try {
const json = JSON.stringify(this.cache);
const base64 = toBase64(json);
const res = await fetch('/api/files/upload', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: this.filename, data: base64 }),
});
if (!res.ok) {
throw new Error(`服务器返回 ${res.status}`);
}
this._savedVersion = Math.max(this._savedVersion, versionToSave);
this._retryCount = 0;
if (this._retryTimer) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
}
return true;
} catch (err) {
console.error('[ServerStorage] 保存失败:', err);
this._retryCount++;
const delay = Math.min(30000, 2000 * (2 ** Math.max(0, this._retryCount - 1)));
if (!this._retryTimer && this._retryCount <= this._maxRetries) {
this._retryTimer = setTimeout(() => {
this._retryTimer = null;
this.saveNow({ silent: true });
}, delay);
}
if (!silent) {
throw err;
}
return false;
} finally {
this._saving = false;
if (this._pendingSave || this._dirtyVersion > this._savedVersion) {
this._saveDebounced();
}
}
}
/** 等待保存完成 */
_waitForSaveComplete() {
return new Promise(resolve => {
const check = () => {
if (!this._saving) resolve();
else setTimeout(check, 50);
};
check();
});
}
clearCache() {
this.cache = null;
this._loading = null;
}
getCacheSize() {
if (!this.cache) return 0;
return Object.keys(this.cache).length;
}
getCacheBytes() {
if (!this.cache) return 0;
try {
return JSON.stringify(this.cache).length * 2;
} catch {
return 0;
}
}
}
export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json');
export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json');
export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 });
export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 });
export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 });

30
core/slash-command.js Normal file
View File

@@ -0,0 +1,30 @@
import { getContext } from "../../../../extensions.js";
/**
* 执行 SillyTavern 斜杠命令
* @param {string} command - 要执行的命令
* @returns {Promise<any>} 命令执行结果
*/
export async function executeSlashCommand(command) {
try {
if (!command) return { error: "命令为空" };
if (!command.startsWith('/')) command = '/' + command;
const { executeSlashCommands, substituteParams } = getContext();
if (typeof executeSlashCommands !== 'function') throw new Error("executeSlashCommands 函数不可用");
command = substituteParams(command);
const result = await executeSlashCommands(command, true);
if (result && typeof result === 'object' && result.pipe !== undefined) {
const pipeValue = result.pipe;
if (typeof pipeValue === 'string') {
try { return JSON.parse(pipeValue); } catch { return pipeValue; }
}
return pipeValue;
}
if (typeof result === 'string' && result.trim()) {
try { return JSON.parse(result); } catch { return result; }
}
return result === undefined ? "" : result;
} catch (err) {
throw err;
}
}

384
core/variable-path.js Normal file
View File

@@ -0,0 +1,384 @@
/**
* @file core/variable-path.js
* @description 变量路径解析与深层操作工具
* @description 零依赖的纯函数模块,供多个变量相关模块使用
*/
/* ============= 路径解析 ============= */
/**
* 解析带中括号的路径
* @param {string} path - 路径字符串,如 "a.b[0].c" 或 "a['key'].b"
* @returns {Array<string|number>} 路径段数组,如 ["a", "b", 0, "c"]
* @example
* lwbSplitPathWithBrackets("a.b[0].c") // ["a", "b", 0, "c"]
* lwbSplitPathWithBrackets("a['key'].b") // ["a", "key", "b"]
* lwbSplitPathWithBrackets("a[\"0\"].b") // ["a", "0", "b"] (字符串"0")
*/
export function lwbSplitPathWithBrackets(path) {
const s = String(path || '');
const segs = [];
let i = 0;
let buf = '';
const flushBuf = () => {
if (buf.length) {
const pushed = /^\d+$/.test(buf) ? Number(buf) : buf;
segs.push(pushed);
buf = '';
}
};
while (i < s.length) {
const ch = s[i];
if (ch === '.') {
flushBuf();
i++;
continue;
}
if (ch === '[') {
flushBuf();
i++;
// 跳过空白
while (i < s.length && /\s/.test(s[i])) i++;
let val;
if (s[i] === '"' || s[i] === "'") {
// 引号包裹的字符串键
const quote = s[i++];
let str = '';
let esc = false;
while (i < s.length) {
const c = s[i++];
if (esc) {
str += c;
esc = false;
continue;
}
if (c === '\\') {
esc = true;
continue;
}
if (c === quote) break;
str += c;
}
val = str;
while (i < s.length && /\s/.test(s[i])) i++;
if (s[i] === ']') i++;
} else {
// 无引号,可能是数字索引或普通键
let raw = '';
while (i < s.length && s[i] !== ']') raw += s[i++];
if (s[i] === ']') i++;
const trimmed = String(raw).trim();
val = /^-?\d+$/.test(trimmed) ? Number(trimmed) : trimmed;
}
segs.push(val);
continue;
}
buf += ch;
i++;
}
flushBuf();
return segs;
}
/**
* 分离路径和值(用于命令解析)
* @param {string} raw - 原始字符串,如 "a.b[0] some value"
* @returns {{path: string, value: string}} 路径和值
* @example
* lwbSplitPathAndValue("a.b[0] hello") // { path: "a.b[0]", value: "hello" }
* lwbSplitPathAndValue("a.b") // { path: "a.b", value: "" }
*/
export function lwbSplitPathAndValue(raw) {
const s = String(raw || '');
let i = 0;
let depth = 0; // 中括号深度
let inQ = false; // 是否在引号内
let qch = ''; // 当前引号字符
for (; i < s.length; i++) {
const ch = s[i];
if (inQ) {
if (ch === '\\') {
i++;
continue;
}
if (ch === qch) {
inQ = false;
qch = '';
}
continue;
}
if (ch === '"' || ch === "'") {
inQ = true;
qch = ch;
continue;
}
if (ch === '[') {
depth++;
continue;
}
if (ch === ']') {
depth = Math.max(0, depth - 1);
continue;
}
// 在顶层遇到空白,分割
if (depth === 0 && /\s/.test(ch)) {
const path = s.slice(0, i).trim();
const value = s.slice(i + 1).trim();
return { path, value };
}
}
return { path: s.trim(), value: '' };
}
/**
* 简单分割路径段(仅支持点号分隔)
* @param {string} path - 路径字符串
* @returns {Array<string|number>} 路径段数组
*/
export function splitPathSegments(path) {
return String(path || '')
.split('.')
.map(s => s.trim())
.filter(Boolean)
.map(seg => /^\d+$/.test(seg) ? Number(seg) : seg);
}
/**
* 规范化路径(统一为点号分隔格式)
* @param {string} path - 路径字符串
* @returns {string} 规范化后的路径
* @example
* normalizePath("a[0].b['c']") // "a.0.b.c"
*/
export function normalizePath(path) {
try {
const segs = lwbSplitPathWithBrackets(path);
return segs.map(s => String(s)).join('.');
} catch {
return String(path || '').trim();
}
}
/**
* 获取根变量名和子路径
* @param {string} name - 完整路径
* @returns {{root: string, subPath: string}}
* @example
* getRootAndPath("a.b.c") // { root: "a", subPath: "b.c" }
* getRootAndPath("a") // { root: "a", subPath: "" }
*/
export function getRootAndPath(name) {
const segs = String(name || '').split('.').map(s => s.trim()).filter(Boolean);
if (segs.length <= 1) {
return { root: String(name || '').trim(), subPath: '' };
}
return { root: segs[0], subPath: segs.slice(1).join('.') };
}
/**
* 拼接路径
* @param {string} base - 基础路径
* @param {string} more - 追加路径
* @returns {string} 拼接后的路径
*/
export function joinPath(base, more) {
return base ? (more ? base + '.' + more : base) : more;
}
/* ============= 深层对象操作 ============= */
/**
* 确保深层容器存在
* @param {Object|Array} root - 根对象
* @param {Array<string|number>} segs - 路径段数组
* @returns {{parent: Object|Array, lastKey: string|number}} 父容器和最后一个键
*/
export function ensureDeepContainer(root, segs) {
let cur = root;
for (let i = 0; i < segs.length - 1; i++) {
const key = segs[i];
const nextKey = segs[i + 1];
const shouldBeArray = typeof nextKey === 'number';
let val = cur?.[key];
if (val === undefined || val === null || typeof val !== 'object') {
cur[key] = shouldBeArray ? [] : {};
}
cur = cur[key];
}
return {
parent: cur,
lastKey: segs[segs.length - 1]
};
}
/**
* 设置深层值
* @param {Object} root - 根对象
* @param {string} path - 路径(点号分隔)
* @param {*} value - 要设置的值
* @returns {boolean} 是否有变化
*/
export function setDeepValue(root, path, value) {
const segs = splitPathSegments(path);
if (segs.length === 0) return false;
const { parent, lastKey } = ensureDeepContainer(root, segs);
const prev = parent[lastKey];
if (prev !== value) {
parent[lastKey] = value;
return true;
}
return false;
}
/**
* 向深层数组推入值(去重)
* @param {Object} root - 根对象
* @param {string} path - 路径
* @param {*|Array} values - 要推入的值
* @returns {boolean} 是否有变化
*/
export function pushDeepValue(root, path, values) {
const segs = splitPathSegments(path);
if (segs.length === 0) return false;
const { parent, lastKey } = ensureDeepContainer(root, segs);
let arr = parent[lastKey];
let changed = false;
// 确保是数组
if (!Array.isArray(arr)) {
arr = arr === undefined ? [] : [arr];
}
const incoming = Array.isArray(values) ? values : [values];
for (const v of incoming) {
if (!arr.includes(v)) {
arr.push(v);
changed = true;
}
}
if (changed) {
parent[lastKey] = arr;
}
return changed;
}
/**
* 删除深层键
* @param {Object} root - 根对象
* @param {string} path - 路径
* @returns {boolean} 是否成功删除
*/
export function deleteDeepKey(root, path) {
const segs = splitPathSegments(path);
if (segs.length === 0) return false;
const { parent, lastKey } = ensureDeepContainer(root, segs);
// 父级是数组
if (Array.isArray(parent)) {
// 数字索引:直接删除
if (typeof lastKey === 'number' && lastKey >= 0 && lastKey < parent.length) {
parent.splice(lastKey, 1);
return true;
}
// 值匹配:删除所有匹配项
const equal = (a, b) => a === b || a == b || String(a) === String(b);
let changed = false;
for (let i = parent.length - 1; i >= 0; i--) {
if (equal(parent[i], lastKey)) {
parent.splice(i, 1);
changed = true;
}
}
return changed;
}
// 父级是对象
if (Object.prototype.hasOwnProperty.call(parent, lastKey)) {
delete parent[lastKey];
return true;
}
return false;
}
/* ============= 值处理工具 ============= */
/**
* 安全的 JSON 序列化
* @param {*} v - 要序列化的值
* @returns {string} JSON 字符串,失败返回空字符串
*/
export function safeJSONStringify(v) {
try {
return JSON.stringify(v);
} catch {
return '';
}
}
/**
* 尝试将原始值解析为对象
* @param {*} rootRaw - 原始值(可能是字符串或对象)
* @returns {Object|Array|null} 解析后的对象,失败返回 null
*/
export function maybeParseObject(rootRaw) {
if (typeof rootRaw === 'string') {
try {
const s = rootRaw.trim();
return (s && (s[0] === '{' || s[0] === '[')) ? JSON.parse(s) : null;
} catch {
return null;
}
}
return (rootRaw && typeof rootRaw === 'object') ? rootRaw : null;
}
/**
* 将值转换为输出字符串
* @param {*} v - 任意值
* @returns {string} 字符串表示
*/
export function valueToString(v) {
if (v == null) return '';
if (typeof v === 'object') return safeJSONStringify(v) || '';
return String(v);
}
/**
* 深度克隆对象(使用 structuredClone 或 JSON
* @param {*} obj - 要克隆的对象
* @returns {*} 克隆后的对象
*/
export function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
try {
return typeof structuredClone === 'function'
? structuredClone(obj)
: JSON.parse(JSON.stringify(obj));
} catch {
return obj;
}
}

272
core/wrapper-inline.js Normal file
View File

@@ -0,0 +1,272 @@
// core/wrapper-inline.js
// iframe 内部注入脚本,同步执行,避免外部加载的时序问题
/**
* 基础脚本:高度测量 + STscript
* 两个渲染器共用
*/
export function getIframeBaseScript() {
return `
(function(){
// vh 修复CSS注入立即生效 + 延迟样式表遍历(不阻塞渲染)
(function(){
var s=document.createElement('style');
s.textContent='html,body{height:auto!important;min-height:0!important;max-height:none!important}';
(document.head||document.documentElement).appendChild(s);
// 延迟遍历样式表,不阻塞初次渲染
(window.requestIdleCallback||function(cb){setTimeout(cb,50)})(function(){
try{
for(var i=0,sheets=document.styleSheets;i<sheets.length;i++){
try{
var rules=sheets[i].cssRules;
if(!rules)continue;
for(var j=0;j<rules.length;j++){
var st=rules[j].style;
if(!st)continue;
if((st.height||'').indexOf('vh')>-1)st.height='auto';
if((st.minHeight||'').indexOf('vh')>-1)st.minHeight='0';
if((st.maxHeight||'').indexOf('vh')>-1)st.maxHeight='none';
}
}catch(e){}
}
}catch(e){}
});
})();
function measureVisibleHeight(){
try{
var doc=document,target=doc.body;
if(!target)return 0;
var minTop=Infinity,maxBottom=0;
var addRect=function(el){
try{
var r=el.getBoundingClientRect();
if(r&&r.height>0){
if(minTop>r.top)minTop=r.top;
if(maxBottom<r.bottom)maxBottom=r.bottom;
}
}catch(e){}
};
addRect(target);
var children=target.children||[];
for(var i=0;i<children.length;i++){
var child=children[i];
if(!child)continue;
try{
var s=window.getComputedStyle(child);
if(s.display==='none'||s.visibility==='hidden')continue;
if(!child.offsetParent&&s.position!=='fixed')continue;
}catch(e){}
addRect(child);
}
return maxBottom>0?Math.ceil(maxBottom-Math.min(minTop,0)):(target.scrollHeight||0);
}catch(e){
return(document.body&&document.body.scrollHeight)||0;
}
}
var parentOrigin;try{parentOrigin=new URL(document.referrer).origin}catch(_){parentOrigin='*'}
function post(m){try{parent.postMessage(m,parentOrigin)}catch(e){}}
var rafPending=false,lastH=0,HYSTERESIS=2;
function send(force){
if(rafPending&&!force)return;
rafPending=true;
requestAnimationFrame(function(){
rafPending=false;
var h=measureVisibleHeight();
if(force||Math.abs(h-lastH)>=HYSTERESIS){
lastH=h;
post({height:h,force:!!force});
}
});
}
try{send(true)}catch(e){}
document.addEventListener('DOMContentLoaded',function(){send(true)},{once:true});
window.addEventListener('load',function(){send(true)},{once:true});
try{
if(document.fonts){
document.fonts.ready.then(function(){send(true)}).catch(function(){});
if(document.fonts.addEventListener){
document.fonts.addEventListener('loadingdone',function(){send(true)});
document.fonts.addEventListener('loadingerror',function(){send(true)});
}
}
}catch(e){}
['transitionend','animationend'].forEach(function(evt){
document.addEventListener(evt,function(){send(false)},{passive:true,capture:true});
});
try{
var root=document.body||document.documentElement;
var ro=new ResizeObserver(function(){send(false)});
ro.observe(root);
}catch(e){
try{
var rootMO=document.body||document.documentElement;
new MutationObserver(function(){send(false)})
.observe(rootMO,{childList:true,subtree:true,attributes:true,characterData:true});
}catch(e){}
window.addEventListener('resize',function(){send(false)},{passive:true});
}
window.addEventListener('message',function(e){
if(parentOrigin!=='*'&&e&&e.origin!==parentOrigin)return;
var d=e&&e.data||{};
if(d&&d.type==='probe')setTimeout(function(){send(true)},10);
});
window.STscript=function(command){
return new Promise(function(resolve,reject){
try{
if(!command){reject(new Error('empty'));return}
if(command[0]!=='/')command='/'+command;
var id=Date.now().toString(36)+Math.random().toString(36).slice(2);
function onMessage(e){
if(parentOrigin!=='*'&&e&&e.origin!==parentOrigin)return;
var d=e&&e.data||{};
if(d.source!=='xiaobaix-host')return;
if((d.type==='commandResult'||d.type==='commandError')&&d.id===id){
try{window.removeEventListener('message',onMessage)}catch(e){}
if(d.type==='commandResult')resolve(d.result);
else reject(new Error(d.error||'error'));
}
}
try{window.addEventListener('message',onMessage)}catch(e){}
post({type:'runCommand',id:id,command:command});
setTimeout(function(){
try{window.removeEventListener('message',onMessage)}catch(e){}
reject(new Error('Command timeout'));
},180000);
}catch(e){reject(e)}
});
};
try{if(typeof window['stscript']!=='function')window['stscript']=window.STscript}catch(e){}
})();`;
}
/**
* CallGenerate + Avatar
* 提供 callGenerate() 函数供角色卡调用
*/
export function getWrapperScript() {
return `
(function(){
function sanitizeOptions(options){
try{
return JSON.parse(JSON.stringify(options,function(k,v){return(typeof v==='function')?undefined:v}))
}catch(_){
try{
var seen=new WeakSet();
var clone=function(val){
if(val===null||val===undefined)return val;
var t=typeof val;
if(t==='function')return undefined;
if(t!=='object')return val;
if(seen.has(val))return undefined;
seen.add(val);
if(Array.isArray(val)){
var arr=[];for(var i=0;i<val.length;i++){var v=clone(val[i]);if(v!==undefined)arr.push(v)}return arr;
}
var proto=Object.getPrototypeOf(val);
if(proto!==Object.prototype&&proto!==null)return undefined;
var out={};
for(var k in val){if(Object.prototype.hasOwnProperty.call(val,k)){var v=clone(val[k]);if(v!==undefined)out[k]=v}}
return out;
};
return clone(options);
}catch(__){return{}}
}
}
function CallGenerateImpl(options){
return new Promise(function(resolve,reject){
try{
var parentOrigin;try{parentOrigin=new URL(document.referrer).origin}catch(_){parentOrigin='*'}
function post(m){try{parent.postMessage(m,parentOrigin)}catch(e){}}
if(!options||typeof options!=='object'){reject(new Error('Invalid options'));return}
var id=Date.now().toString(36)+Math.random().toString(36).slice(2);
function onMessage(e){
if(parentOrigin!=='*'&&e&&e.origin!==parentOrigin)return;
var d=e&&e.data||{};
if(d.source!=='xiaobaix-host'||d.id!==id)return;
if(d.type==='generateStreamStart'&&options.streaming&&options.streaming.onStart){try{options.streaming.onStart(d.sessionId)}catch(_){}}
else if(d.type==='generateStreamChunk'&&options.streaming&&options.streaming.onChunk){try{options.streaming.onChunk(d.chunk,d.accumulated)}catch(_){}}
else if(d.type==='generateStreamComplete'){try{window.removeEventListener('message',onMessage)}catch(_){}resolve(d.result)}
else if(d.type==='generateStreamError'){try{window.removeEventListener('message',onMessage)}catch(_){}reject(new Error(d.error||'Stream failed'))}
else if(d.type==='generateResult'){try{window.removeEventListener('message',onMessage)}catch(_){}resolve(d.result)}
else if(d.type==='generateError'){try{window.removeEventListener('message',onMessage)}catch(_){}reject(new Error(d.error||'Generation failed'))}
}
try{window.addEventListener('message',onMessage)}catch(_){}
var sanitized=sanitizeOptions(options);
post({type:'generateRequest',id:id,options:sanitized});
setTimeout(function(){try{window.removeEventListener('message',onMessage)}catch(e){};reject(new Error('Generation timeout'))},300000);
}catch(e){reject(e)}
});
}
try{window.CallGenerate=CallGenerateImpl}catch(e){}
try{window.callGenerate=CallGenerateImpl}catch(e){}
try{window.__xb_callGenerate_loaded=true}catch(e){}
})();
(function(){
function applyAvatarCss(urls){
try{
var root=document.documentElement;
root.style.setProperty('--xb-user-avatar',urls&&urls.user?'url("'+urls.user+'")':'none');
root.style.setProperty('--xb-char-avatar',urls&&urls.char?'url("'+urls.char+'")':'none');
if(!document.getElementById('xb-avatar-style')){
var css='.xb-avatar,.xb-user-avatar,.xb-char-avatar{width:36px;height:36px;border-radius:50%;background-size:cover;background-position:center;background-repeat:no-repeat;display:inline-block}.xb-user-avatar{background-image:var(--xb-user-avatar)}.xb-char-avatar{background-image:var(--xb-char-avatar)}';
var style=document.createElement('style');
style.id='xb-avatar-style';
style.textContent=css;
document.head.appendChild(style);
}
}catch(_){}
}
var parentOrigin;try{parentOrigin=new URL(document.referrer).origin}catch(_){parentOrigin='*'}
function requestAvatars(){try{parent.postMessage({type:'getAvatars'},parentOrigin)}catch(_){}}
function onMessage(e){
if(parentOrigin!=='*'&&e&&e.origin!==parentOrigin)return;
var d=e&&e.data||{};
if(d&&d.source==='xiaobaix-host'&&d.type==='avatars'){
applyAvatarCss(d.urls);
try{window.removeEventListener('message',onMessage)}catch(_){}
}
}
try{
window.addEventListener('message',onMessage);
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',requestAvatars,{once:true});}
else{requestAvatars();}
window.addEventListener('load',requestAvatars,{once:true});
}catch(_){}
})();`;
}
/**
* 模板变量更新template-editor 独有)
*/
export function getTemplateExtrasScript() {
return `
(function(){
if(typeof window.updateTemplateVariables!=='function'){
window.updateTemplateVariables=function(variables){
try{
Object.entries(variables||{}).forEach(function(entry){
var k=entry[0],v=entry[1];
document.querySelectorAll('[data-xiaobaix-var="'+k+'"]').forEach(function(el){
if(v==null)el.textContent='';
else if(Array.isArray(v))el.textContent=v.join(', ');
else if(typeof v==='object')el.textContent=JSON.stringify(v);
else el.textContent=String(v);
el.style.display='';
});
});
}catch(e){}
try{window.dispatchEvent(new Event('contentUpdated'))}catch(e){}
};
}
})();`;
}